Compare commits

129 Commits
Author SHA1 Message Date
Andrew Stryker 2a1b1973ab Upgrade PaperMode
Attempted to fix warnings, but it did not.
2026-07-11 23:31:06 -07:00
Andrew Stryker c27ac500db Have Hugo process the resume html as if it is Markdown 2026-07-11 23:14:30 -07:00
Andrew Stryker 706601950c Make the resume static content to bypass security warning 2026-07-11 23:00:46 -07:00
Andrew Stryker 0dd6fe35ed Change languageCode to locale 2026-07-11 22:51:25 -07:00
Andrew Stryker 59095c952f Handle sentinel for builds correctly 2026-05-03 20:31:18 -07:00
Andrew Stryker 64c772e216 Update PaperMod 2026-05-03 18:27:31 -07:00
Andrew Stryker c7396a5fd4 Complete the draft 2026-05-03 18:19:07 -07:00
Andrew Stryker 82f719f734 Correct slug 2026-05-03 18:02:40 -07:00
Andrew Stryker 9e323ee4c4 Continue edits 2026-05-03 18:00:27 -07:00
Andrew Stryker 127715abfb Merge branch 'main' into posts/2026-04-12-mcp-for-you-data-warehouse 2026-05-01 16:29:56 -07:00
Andrew Stryker 84b2336a18 Perform an editting pass 2026-05-01 16:29:36 -07:00
Andrew Stryker b300f8d733 Update from _build to build 2026-04-20 21:48:34 -07:00
Andrew Stryker 780d24a491 Hide the share index 2026-04-20 20:39:00 -07:00
Andrew Stryker 28f6c9f89e Consolidate tags 2026-04-20 09:40:39 -07:00
Andrew Stryker 172a9c6965 Merge branch 'infra/mastodon-share' 2026-04-20 09:38:28 -07:00
Andrew Stryker 8579907491 Refine sharing 2026-04-20 09:38:19 -07:00
Andrew Stryker 20c5086509 Shorten the ontology foundations path 2026-04-20 09:17:53 -07:00
Andrew Stryker 93365efdc4 Internalize Mastodon sharing 2026-04-20 09:13:22 -07:00
Andrew Stryker 24cba59620 Correct branch naming with slugs 2026-04-20 08:46:30 -07:00
Andrew Stryker 2f75dd84cd Complete draft
Need one editorial, language pass. Technical work is done.
2026-04-20 08:45:18 -07:00
Andrew Stryker 4fa625500d Apply minor updates 2026-04-19 12:00:25 -07:00
Andrew Stryker a55915a310 Add summary to Bayes note
Without the summary, the notes list shows math, which may or may not render
correctly.
2026-04-19 11:46:50 -07:00
Andrew Stryker 524a985378 Complete Wharton page 2026-04-19 11:41:41 -07:00
Andrew Stryker af4974de7e Place social sharing in the correct place 2026-04-19 10:49:23 -07:00
Andrew Stryker 522c023671 Edit draft 2026-04-19 10:38:06 -07:00
Andrew Stryker 338f9f38ef Complete initial draft 2026-04-12 17:05:07 -07:00
Andrew Stryker 5fd6d1dc1e Automate creating a new branch 2026-04-12 15:17:22 -07:00
Andrew Stryker 96340594db Update notes for Hugo detail shortcode 2026-04-06 09:53:11 -07:00
Andrew Stryker b446d243ec Update resume for the correct Quadrant4 spelling 2026-04-06 09:11:19 -07:00
Andrew Stryker d72b1f3cf4 Merge branch 'wharton-certicates' 2026-04-06 09:06:00 -07:00
Andrew Stryker d3fbb70d4b Add tags to ontology 2026-03-31 09:38:58 -07:00
Andrew Stryker 0fb13773a7 Enable sharing in lists 2026-03-31 09:38:25 -07:00
Andrew Stryker 7b3c44f56d Take warehouse ontology out of draft 2026-03-31 08:43:19 -07:00
Andrew Stryker 259969d252 Add missing word 2026-03-30 22:32:37 -07:00
Andrew Stryker dd37c2b4f7 Generalize the Rmd to Md pattern 2026-03-30 22:19:45 -07:00
Andrew Stryker 288eaa8ccb Move to page bundle and isolate the simulator 2026-03-30 22:19:19 -07:00
Andrew Stryker f20c90a91c Name the layer in the footnote 2026-03-30 18:42:45 -07:00
Andrew Stryker b107f4958c Use the date prefix in the directory 2026-03-30 18:40:26 -07:00
Andrew Stryker d8742a04b7 Add the ontology tech note 2026-03-30 18:39:49 -07:00
Andrew Stryker 7aa0b1f40f Finalize the onotology post 2026-03-30 18:39:29 -07:00
Andrew Stryker ee5b9f7bcb Configure Mermaid for a native look 2026-03-30 16:47:08 -07:00
Andrew Stryker f10ca11257 Add a summaary 2026-03-25 20:26:10 -07:00
Andrew Stryker d91a5c7c9e Consolidate chunk opts 2026-03-25 17:53:15 -07:00
Andrew Stryker 260dfabc9c Add a reactable theme 2026-03-25 16:58:53 -07:00
Andrew Stryker 376add89a9 Finalize post 2026-03-25 16:58:39 -07:00
Andrew Stryker efb99a6875 Merge branch 'main' into post/autogolpe 2026-03-25 12:51:02 -07:00
Andrew StrykerandClaude Opus 4.6 433d2b6ad4 Override PaperMod author partial to handle map format
PaperMod's author.html expects params.author to be a string, but
our config uses a map with name and email (needed for RSS). Add a
reflect.IsMap check to extract .name for display, matching how the
updated PaperMod RSS template already handles the map format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:33:46 -07:00
Andrew StrykerandClaude Opus 4.6 8ff7fb63e6 Fix Mermaid rendering: move JS to extend_head, fix hook path
Three issues prevented Mermaid diagrams from rendering:

1. Render hook was in layouts/_defaults/ (with 's') instead of
   layouts/_default/ — Hugo never found it
2. The .Store.Set approach in the render hook couldn't communicate
   with extend_footer because PaperMod caches the footer partial
   via partialCached, so per-page state is lost
3. Moved Mermaid JS loading to extend_head (not cached) using the
   same xparams.mermaid front matter flag pattern as KaTeX

Pages opt in with xparams.mermaid: true in front matter. The code
block render hook still converts ```mermaid fences to <pre> tags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:20:05 -07:00
Andrew Stryker d83f47556f Merge branch 'main' into demo/reactable 2026-03-25 09:57:54 -07:00
Andrew StrykerandClaude Opus 4.6 70bf848fa3 Add demos section with integration tests for site capabilities
Move reactable demo from content/posts/ to content/demos/ so it
doesn't appear in the posts list. Add demo pages for:

- Mermaid: flowchart, sequence, and state diagrams via code blocks
- KaTeX: inline and display math via the math partial
- Shortcodes: toc, rawhtml, youtube, vimeo

All demos are draft: true and in a dedicated section, so they
build with hugo --buildDrafts but are never published.

Also widen Makefile RMD_SOURCES glob to include content/demos/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:47:37 -07:00
Andrew StrykerandClaude Opus 4.6 4e7959076e Update statdown to cebe5e27 (front matter preservation fix)
statdown now inserts CSS/JS dependency tags after the YAML front
matter closing delimiter instead of before it, fixing Hugo's
inability to parse the front matter in rendered widget posts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:38:28 -07:00
Andrew StrykerandClaude Opus 4.6 51440bc030 Add reactable demo and fix renv project detection in Makefile
- Add reactable-demo post exercising the full statdown/depkit
  htmlwidget pipeline (two reactable tables with filtering)
- Replace renv::restore() with renv::load() using explicit project
  root in Makefile and watch-rmd.sh — renv could not find the
  lockfile when cd'd into post subdirectories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:25:13 -07:00
Andrew Stryker e47d677360 Update resume 2026-03-25 09:15:59 -07:00
Andrew Stryker 751a7f17fa Merge branch 'main' into about/resume 2026-03-24 22:30:21 -07:00
Andrew Stryker 4348215ebd Merge branch 'infra/clean-up' 2026-03-24 22:30:05 -07:00
Andrew StrykerandClaude Opus 4.6 b23591be41 Fix Hugo config and update PaperMod to latest
- Update PaperMod submodule (v8.0+25 -> v8.0+62), which fixes
  the RSS build error from site.Author removal in Hugo 0.159
- Replace deprecated renderhooks.link.enableDefault with
  useEmbedded: "fallback" (deprecated since Hugo 0.148)
- Add params.author (name, email) for RSS feed metadata

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:28:43 -07:00
Andrew Stryker 35ed25f8bb Update the date 2026-03-24 22:25:53 -07:00
Andrew StrykerandClaude Opus 4.6 cc3aa74b46 Replace resume.md with resume.html
Use the pandoc-generated narrative resume HTML, replacing the
handwritten markdown version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:23:41 -07:00
Andrew StrykerandClaude Opus 4.6 7280c9a623 Fix archetype routing and narrow build sentinel dependencies
- Move Rmd archetype from _default/ to rmarkdown/ so hugo-new.sh's
  --kind rmarkdown resolves to the correct archetype instead of
  working by accident via file extension fallback
- Replace broad content/*/* glob in .build_sentinel with explicit
  find for content (.md, .html, images), layouts, and hugo.yaml
- Fix typo in Makefile comment ("my" -> "by")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:19:13 -07:00
Andrew StrykerandClaude Opus 4.6 6848c6ca7e Pin R environment with statdown and depkit via renv
- Add DESCRIPTION with explicit dependencies and GitHub remotes
  for statdown and depkit
- Switch renv snapshot type from "implicit" to "explicit" so
  dependencies are declared, not scanned from source files
- Regenerate renv.lock: adds statdown, depkit, httr2, svglite;
  bumps 9 CRAN packages; pins GitHub SHAs for custom packages
- Rename scripts/renv.lock to scripts/install-packages.R (it was
  an R install script, not a lockfile)
- Bump renv 1.1.7 -> 1.1.8

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:04:30 -07:00
Andrew StrykerandClaude Opus 4.6 c54c12b141 Bump KaTeX to 0.16.42 and pin Mermaid to 11.13.0
- KaTeX: 0.16.21 -> 0.16.42 with updated SRI hashes
- Mermaid: pin to 11.13.0 (was unpinned, resolving to latest)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:50:14 -07:00
Andrew StrykerandClaude Opus 4.6 0986654ea7 Clean up infrastructure: remove dead code, fix watch script, fix typos
- Remove hugo.sh (broken draft superseded by hugo-new.sh)
- Remove unused mermaid shortcode and partial (only code-block
  rendering via render-codeblock-mermaid.html is used)
- Remove dead xparams.mermaid check from extend_head.html
- Fix watch-rmd.sh to pass shared libs args matching the Makefile
- Fix typos in Makefile ("permsions", "Publising")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:47:41 -07:00
Andrew Stryker 04aa189f8a Add hugo helpers 2026-03-24 21:41:09 -07:00
Andrew Stryker 82a6d16d59 Restructure bayes as odds 2026-03-24 21:40:19 -07:00
Andrew Stryker 173c1a37e7 Ignore figures and libs 2026-03-24 21:39:25 -07:00
Andrew StrykerandClaude Opus 4.6 80ea5b1c2c Add shared libs directory to gitignore and Makefile
Configure statdown to write CSS/JS dependencies to static/libs/
so they are shared across posts, and ignore that directory in git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:36:00 -07:00
Andrew StrykerandClaude Opus 4.6 403da45ff1 Merge infrastructure changes from post/autogolpe into main
Brings in renv, archetypes, layout partials, shortcodes, gitignore,
and hugo config changes that were committed on the post branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:33:44 -07:00
axs 32094e7049 Add archetypes 2026-03-02 07:06:36 -08:00
axs 475af38bee Refine layout 2026-03-02 07:05:09 -08:00
axs 7983a30f4e Use statdown and Renv 2026-03-02 07:03:31 -08:00
axs d07972b407 Ignore R files 2026-01-12 10:53:51 -08:00
axs f1d4cac578 Add note on using the div tag 2025-04-08 08:26:41 -07:00
axs ad6714001b Fix file names 2025-03-26 23:19:15 -07:00
axs b1873be1f3 Allow raw HTML 2025-03-26 23:15:07 -07:00
axs bea242adaf Add notes on Bayes theorem as odds 2025-03-26 23:14:02 -07:00
axs 10f5f93dc3 Add partials for react components 2025-03-26 23:12:54 -07:00
Andrew Stryker df80ad6c65 Merge branch 'post/autogolpe' 2025-03-14 20:26:05 -07:00
Andrew Stryker 2eec0c6dcb Enable render hooks 2025-03-14 20:25:55 -07:00
Andrew Stryker 216ca36215 Continue refining 2025-03-14 20:22:55 -07:00
Andrew Stryker 1f15d89f89 Ignore derived files 2025-03-14 20:18:52 -07:00
Andrew Stryker a9e2cbf36a Update link to supporting note 2025-03-12 14:15:52 -07:00
Andrew Stryker 2d1c1b0d58 Delete stray file 2025-03-12 14:14:05 -07:00
Andrew Stryker 5000bf8e3c Add permalinks and ignores 2025-03-12 14:13:50 -07:00
Andrew Stryker 730c03a868 Move to a page bundle organization 2025-03-12 14:13:24 -07:00
Andrew Stryker 4604e26540 Take the note on Bayes odds as out of draft 2025-03-10 22:07:45 -07:00
Andrew Stryker ae8fc209be Update Bayesian odds note
- Fix derivation
- Group results
- Add references
- Describe the result in English
2025-03-10 21:58:56 -07:00
Andrew Stryker 421ec655e9 Move from "social" to "share"
The theme uses `.Site.Params.social` for Twitter Cards and possible other
things. To avoid the name clash, this change places configuration under
`.Site.Params.share` and `xparams.share`.
2025-03-10 21:36:21 -07:00
Andrew Stryker 7aa4e6711f Set include to false for most platforms 2025-03-10 09:59:04 -07:00
Andrew Stryker 8cac765335 Include math and remove unhelpful text 2025-03-10 09:11:43 -07:00
Andrew Stryker df71a6b41c Cache list of default platforms 2025-03-10 09:10:57 -07:00
Andrew Stryker 55909de2b5 Include more sharing options 2025-03-10 09:10:26 -07:00
Andrew Stryker 32d2c5add9 Select Mastodon share links by class
This gives me some flexibility to include multiple anchors on a page.
Not sure if this is really needed. However, this is better than listening
to an id that might not be unique.
2025-03-10 09:07:46 -07:00
Andrew Stryker d2279a776e Extend render to for generic sharing 2025-03-09 18:41:12 -07:00
Andrew Stryker e956d0a6bf Revise Mastodon sharing approach
The partial now uses a short JavaScript:
  - Attempts to use the web+mastodon protocol to open a client
  - Falls back to a redirect if that does not work

This is (hopefully) a pragmatic, maintainable, and future proof
solution.
2025-03-09 11:47:16 -07:00
Andrew Stryker 1ad22709b3 Remove unused variable
Setting an instance for a federated service does not make sense. The new
approach is to ask the user which instance to use.
2025-03-08 19:02:18 -08:00
Andrew Stryker 26d8fff5b2 Ask user for Mastodon instance 2025-03-08 19:02:00 -08:00
Andrew Stryker 7f5273b800 Add reference to likelihood ratio 2025-03-08 18:41:29 -08:00
Andrew Stryker 59c77c919d Link to the Wikipedia article on Bayes theorem 2025-03-08 18:38:44 -08:00
Andrew Stryker 1c501fa0dc Compplete Bayes as odds note 2025-03-08 18:34:54 -08:00
Andrew Stryker 14d8dee519 Organize social sharing 2025-03-08 18:33:13 -08:00
Andrew Stryker 92befd869c Update theme 2025-03-08 18:32:33 -08:00
Andrew Stryker 427d9896f4 Add shortcodes for including content 2025-03-08 18:31:52 -08:00
Andrew Stryker 34303b27e3 Support social sharing 2025-03-08 18:30:11 -08:00
Andrew Stryker fbb2a57fbf Fix typo 2025-03-06 21:28:20 -08:00
Andrew Stryker 3acd847a4e Complete note on expressing Bayes in term os odds 2025-03-06 21:27:15 -08:00
Andrew Stryker cafce77e62 Complete intro 2025-03-02 22:18:15 -08:00
Andrew Stryker 2d918be1ff Remove Mastodon verification link 2025-03-02 18:57:37 -08:00
Andrew Stryker 7340422162 Place a mastodon verification link in the home page footer 2025-03-01 12:33:33 -08:00
Andrew Stryker 332539a898 Start Bayesian analysis of auogolpe 2025-03-01 10:22:51 -08:00
Andrew Stryker 6d9984acde Move to xparams for organizing parameters 2025-02-28 17:01:21 -08:00
Andrew Stryker a5f12ddab1 Use KaTeX partial recommended in Hugo documentation 2025-02-28 16:56:27 -08:00
Andrew Stryker a6803016fa Fix location of goldmark configuration 2025-02-28 16:55:18 -08:00
Andrew Stryker e5d5886354 Override access the tags page
Apache, as configured on SDF, was intercepting and blocking access to
the tags directory.
2025-02-27 06:58:56 -08:00
Andrew Stryker a4c5d3fb91 Remove unintended file 2025-02-27 06:58:38 -08:00
Andrew Stryker 2702c8104c Remove --cvs-exclude
We don't need this option, given that are working with a completely
generated directory structure. Furthers, `tags` is part of the ignore
file.
2025-02-26 21:57:52 -08:00
Andrew Stryker c683d2aba4 Tweak language 2025-02-26 20:15:53 -08:00
Andrew Stryker 81e54db2fc Add Mermaid shortcode 2025-02-26 20:11:59 -08:00
Andrew Stryker 9f6a5b44f8 Add basic partials 2025-02-26 20:10:48 -08:00
Andrew Stryker 02491d3c4e Add tags to site 2025-02-26 20:10:09 -08:00
Andrew Stryker c2de0fa819 Add tags the HTML recommendation 2025-02-26 20:03:13 -08:00
Andrew Stryker a99440ea2a Update abbr note 2025-02-26 19:29:50 -08:00
Andrew Stryker 40f9cea0a0 Update abbr note 2025-02-26 19:29:08 -08:00
Andrew Stryker fd4a98cb22 Simplify footnotes 2025-02-26 19:23:43 -08:00
Andrew Stryker 3f099eb33c Add HTML in Markdown notes 2025-02-26 18:14:41 -08:00
Andrew Stryker 5a6e2cfdaa Add site-specific configuration 2025-02-26 14:25:01 -08:00
Andrew Stryker b76003d28f Configure goldmark for LaTeX 2025-02-26 14:23:59 -08:00
Andrew Stryker 9895018443 Change to monokai syntax style 2025-02-26 14:23:06 -08:00
Andrew Stryker 48ef81f14a Fix highlight property key spelling 2025-02-26 14:22:18 -08:00
Andrew Stryker cbecc2b34d Update PaperMod 2025-02-26 14:20:54 -08:00
65 changed files with 9413 additions and 162 deletions
+1
View File
@@ -0,0 +1 @@
source("renv/activate.R")
View File
+62 -2
View File
@@ -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
View File
@@ -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
+33 -6
View File
@@ -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"
+8
View File
@@ -0,0 +1,8 @@
---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
slug: "{{ .Name }}"
tags: []
categories: []
draft: true
---
+16
View File
@@ -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)
```
+58
View File
@@ -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 -5
View File
@@ -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
View File
@@ -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 Mobiles
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 2049May 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 Apples App Tracking Transparency initiative to
maintain acquisition budgets.} Apples
\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 Whartons 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 wasnt 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 companys 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 Apples 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 companys 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 companys 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 companys 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 utilitys 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 regions 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>. &ldquo;Tangled: Isolating SEM Savings.&rdquo; <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>. &ldquo;Why the Light Bulb is No Longer a Textbook Example for Price Elasticity: Results from Choice Experiments and Demand Modeling Research.&rdquo; <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>. &ldquo;Traveler Response to System Change, Chapter 17: Transit-Oriented Development.&rdquo; <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>. &ldquo;The Ohio Long Distance Travel Model.&rdquo; <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>. &ldquo;A Practical Evaluation of Alternative Methods to Trip Distribution.&rdquo; <em class="resume-pub-venue">Transportation Research Board Planning Applications Conference</em>, 2005.</div>
</section>
+16 -42
View File
@@ -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
![Chief Technology Officer](https://api.accredible.com/v1/frontend/credential_website_embed_image/certificate/106749367)
# Scaling a Unicorn
## Scaling a Unicorn
The Scaling a Unicorn elective covered:
![Scaling a Unicorn](https://api.accredible.com/v1/frontend/credential_website_embed_image/certificate/106997180)
- Thing 1
- Thing 2
- Thing 3
## Driving Strategic Innovation
![Scaling a
Unicorn](https://api.accredible.com/v1/frontend/credential_website_embed_image/certificate/106997180)
![Driving Strategic Innovation](https://api.accredible.com/v1/frontend/credential_website_embed_image/certificate/106709316)
# Driving Strategic Innovation
## Executive Presence and Influence
The Driving Strategic Innovation elective covered:
- Thing 1
- Thing 2
- Thing 3
![Driving Strategic
Innovation](https://api.accredible.com/v1/frontend/credential_website_embed_image/certificate/106709316)
The Es
- Thing 1
- Thing 2
- Thing 3
![Executive Presence and
Influence](https://api.accredible.com/v1/frontend/credential_website_embed_image/certificate/103003049)
![Executive Presence and Influence](https://api.accredible.com/v1/frontend/credential_website_embed_image/certificate/103003049)
+6
View File
@@ -0,0 +1,6 @@
---
title: Demos
draft: true
---
Integration tests for site capabilities. Build and visually inspect.
+34
View File
@@ -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}
\]
+48
View File
@@ -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 --> [*]
```
+3
View File
@@ -0,0 +1,3 @@
index.md
figure/
libs/
+40
View File
@@ -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
)
```
+33
View File
@@ -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 >}}
+3 -1
View File
@@ -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.
+111
View File
@@ -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 Markdowns 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 wont 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. &quot;Long Live the King&quot; (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|&not;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/
+6
View File
@@ -0,0 +1,6 @@
---
title: Share
build:
render: never
list: never
---
+9
View File
@@ -0,0 +1,9 @@
---
title: "Share on Mastodon"
layout: "mastodon-share"
robotsNoIndex: true
build:
list: never
sitemap:
priority: 0
---
Executable
+73
View File
@@ -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}"
+73 -12
View File
@@ -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>
+123
View File
@@ -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 }}">
«&nbsp;{{ i18n "prev_page" }}&nbsp;
{{- 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" }}&nbsp;
{{- if (.Param "ShowPageNums") }}
{{- add 1 $paginator.PageNumber }}/{{ $paginator.TotalPages }}
{{- end }}&nbsp;»
</a>
{{- end }}
</nav>
</footer>
{{- end }}
{{- partial "share_icons.html" . -}}
{{- end }}{{/* end profileMode */}}
{{- end }}{{- /* end main */ -}}
+160
View File
@@ -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 }}
+63
View File
@@ -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 */}}
+10
View File
@@ -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 -}}
+3
View File
@@ -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 }}
+12
View File
@@ -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" . -}}
+35
View File
@@ -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>
+29
View File
@@ -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>
+16
View File
@@ -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 -}}
+7
View File
@@ -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 }}
+3
View File
@@ -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>
+27
View File
@@ -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 -}}
+20
View File
@@ -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>
+17
View File
@@ -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" .) -}}
+1
View File
@@ -0,0 +1 @@
{{ if eq (getenv "HUGO_BLOGDOWN_POST_RELREF") "true" }}{{ .Page.RelPermalink }}{{ else }}{{ .Page.Permalink }}{{ end }}
+18
View File
@@ -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>
+6
View File
@@ -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>
+8
View File
@@ -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 }}
+18
View File
@@ -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>
+19
View File
@@ -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>
+4233
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
library/
local/
cellar/
lock/
python/
sandbox/
staging/
+1419
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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
}
+11
View File
@@ -0,0 +1,11 @@
pak::pkg_install(
c(
"knitr",
"tidyverse",
"reactable",
"htmltools",
"svglite"
)
)
renv::snapshot()
# vim: ft=r
+19
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
RedirectMatch 301 ^/share/?$ /
+2
View File
@@ -0,0 +1,2 @@
RewriteEngine Off
DirectoryIndex index.html