<?xml version="1.0" encoding="UTF-8"?>
<rss  xmlns:atom="http://www.w3.org/2005/Atom" 
      xmlns:media="http://search.yahoo.com/mrss/" 
      xmlns:content="http://purl.org/rss/1.0/modules/content/" 
      xmlns:dc="http://purl.org/dc/elements/1.1/" 
      version="2.0">
<channel>
<title>Matt Hodges</title>
<link>https://matthodges.com/posts.html</link>
<atom:link href="https://matthodges.com/posts.xml" rel="self" type="application/rss+xml"/>
<description>I reserve the right to change my mind.</description>
<image>
<url>https://matthodges.com/img/social.jpg</url>
<title>Matt Hodges</title>
<link>https://matthodges.com/posts.html</link>
</image>
<generator>quarto-1.9.36</generator>
<lastBuildDate>Wed, 19 Aug 2026 05:00:00 GMT</lastBuildDate>
<item>
  <title>Bongard Problems</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2026-08-19-bongard-problems/</link>
  <description><![CDATA[ 





<p>Bongard problems are interesting puzzles; they’re sort of like <a href="https://en.wikipedia.org/wiki/Spot_the_difference">Spot The Difference</a> meets <a href="https://en.wikipedia.org/wiki/Raven%27s_Progressive_Matrices">Raven’s Progressive Matrices</a>. When presented with two sets of images, the challenge is to identify the latent property that is shared by everything on the left but absent (or different) from everything on the right.</p>
<p>Here’s a simple Bongard problem, created by <a href="https://www.foundalis.com/res/Mikhail_Moiseevich_Bongard.html">Mikhail Moiseevich Bongard</a>:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2026-08-19-bongard-problems/bongard-problem-10.png" class="img-fluid figure-img"></p>
<figcaption><a href="https://oebp.org/present.php?bp=10">Bongard problem 10</a>, by Mikhail Moiseevich Bongard</figcaption>
</figure>
</div>
<div class="callout callout-style-simple callout-note no-icon callout-titled">
<div class="callout-header d-flex align-content-center collapsed" data-bs-toggle="collapse" data-bs-target=".callout-1-contents" aria-controls="callout-1" aria-expanded="false" aria-label="Toggle callout">
<div class="callout-icon-container">
<i class="callout-icon no-icon"></i>
</div>
<div class="callout-title-container flex-fill">
<span class="screen-reader-only">Note</span>Reveal Solution
</div>
<div class="callout-btn-toggle d-inline-block border-0 py-1 ps-1 pe-0 float-end"><i class="callout-toggle"></i></div>
</div>
<div id="callout-1" class="callout-1-contents callout-collapse collapse">
<div class="callout-body-container callout-body">
<p><strong>Solution:</strong> Shapes on the left form approximately triangular outlines, while shapes on the right form approximately quadrilateral outlines.</p>
</div>
</div>
</div>
<p>And here’s a trickier Bongard problem, created by <a href="https://en.wikipedia.org/wiki/Douglas_Hofstadter">Douglas Hofstadter</a>:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2026-08-19-bongard-problems/bongard-problem-155.png" class="img-fluid figure-img"></p>
<figcaption><a href="https://oebp.org/present.php?bp=155">Bongard problem 155</a>, by Douglas Hofstadter</figcaption>
</figure>
</div>
<div class="callout callout-style-simple callout-note no-icon callout-titled">
<div class="callout-header d-flex align-content-center collapsed" data-bs-toggle="collapse" data-bs-target=".callout-2-contents" aria-controls="callout-2" aria-expanded="false" aria-label="Toggle callout">
<div class="callout-icon-container">
<i class="callout-icon no-icon"></i>
</div>
<div class="callout-title-container flex-fill">
<span class="screen-reader-only">Note</span>Reveal Solution
</div>
<div class="callout-btn-toggle d-inline-block border-0 py-1 ps-1 pe-0 float-end"><i class="callout-toggle"></i></div>
</div>
<div id="callout-2" class="callout-2-contents callout-collapse collapse">
<div class="callout-body-container callout-body">
<p><strong>Solution:</strong> On the left, curves are longer than straight lines, while on the right, curves are shorter than straight lines.</p>
</div>
</div>
</div>
<p>I first learned about Bongard problems while reading <a href="https://en.wikipedia.org/wiki/G%C3%B6del,_Escher,_Bach">Gödel, Escher, Bach</a>, when Hofstadter introduced them in his chapter <em>Artificial Intelligence: Prospects</em>. <a href="https://matthodges.com/posts/2025-04-21-openai-o4-mini-high-mu-puzzle/">I’ve returned to Hofstadter’s puzzles before</a>, but Bongard problems are especially interesting because he used them to imagine, in 1979, what a visual reasoning program might look like:</p>
<p>First, he imagined a <strong>preprocessing</strong> stage that detects salient features that map to a mini-vocabulary of known concept terms like, <em>line segment</em>, or <em>curve</em>, or <em>horizontal</em>. From there, preprocessing applies its knowledge of elementary shapes to get to terms like, <em>circle</em>, or <em>right angle</em>, or <em>vertex</em>. There’s a resemblance here to what we now call <a href="https://en.wikipedia.org/wiki/Representation_learning">representation learning</a>. In <a href="https://matthodges.com/posts/2022-08-06-neural-network-from-scratch-python-numpy/">image classifiers built as convolutional networks</a>, the network learns internal representations useful for discriminating images, though as distributed numerical representations rather than as tidy vocabulary of named concepts. And <a href="https://en.wikipedia.org/wiki/Contrastive_Language%E2%80%93Image_Pre-training">CLIP</a> is an especially interesting modern comparison because it learns image and text representations in a shared space, allowing natural-language descriptions to refer to learned visual concepts.</p>
<p>At this point in Hofstadter’s imaginary program, the picture is “understood” at the basic level of mapping input images to labels. The next stage is a search for <strong>high-level descriptions</strong> about the features. The program “looks around” to spot descriptors like <em>to the right of</em>, or <em>perpendicular to</em>, or <em>evenly spaced</em>. It can also build <strong>descriptions of descriptions</strong>, looking for regularities across the ways individual images have been described. There’s a resemblance here to modern work on <a href="https://deepmind.google/blog/a-neural-approach-to-relational-reasoning/">relational reasoning</a>, where models try to represent not only the objects in an image but also the relationships among them.</p>
<p>But simply generating more descriptions doesn’t solve a Bongard problem. The program also has to decide where to <strong>focus</strong> and which kinds of properties to <strong>filter</strong> for. A description can be perfectly true and still be useless for distinguishing the two sides. The right abstraction may only become visible after comparing several images, changing what seems important, and returning to an earlier description with a different idea of what to look for.</p>
<p>Hofstadter suggested various approaches and heuristics for making that search less brittle. He imagined <strong>templates and sameness-detectors</strong> that could trigger when several examples began to converge on the same description. There’s a loose modern parallel in <a href="https://en.wikipedia.org/wiki/Meta-learning_%28computer_science%29#Prototypical_Networks">some approaches to meta-learning</a>, where models learn a space in which examples can be classified by their distance from a common prototype. He also suggested a <strong>semantic net</strong> in which <em>“all the known nouns, adjectives, etc., are linked in ways which indicate their interrelations.”</em> That sounds a lot like <a href="https://en.wikipedia.org/wiki/Word_embedding">word embeddings</a>.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2026-08-19-bongard-problems/geb-concept-network.jpg" class="img-fluid figure-img"></p>
<figcaption>A portion of Hofstadter’s proposed concept network for a Bongard problem-solving program</figcaption>
</figure>
</div>
<p>Importantly, Hofstadter didn’t want early concepts or hypotheses to be rigid. An idea that didn’t quite fit might be weakened, modified, or allowed to <strong>slip</strong> toward a related concept rather than simply discarded. A shape might be treated as a rough instance of something it doesn’t satisfy exactly, or a group of objects might become a single higher-level object once the problem suggests looking at it that way. There’s a connection here to <a href="https://en.wikipedia.org/wiki/Analogy">analogical reasoning</a>, where representations of relationships need to be flexible enough to map across different situations. More broadly, Hofstadter’s program depends on representations remaining <strong>tentative</strong> enough that higher-level hypotheses can change how the underlying images are described.</p>
<p>The chapter continues to dig into more layers of what this Bongard problem-solving program might require, and how it maps to various forms of recognition, grouping, filtering, and focusing. It’s not the only place in GEB where Hofstadter’s ideas <a href="https://matthodges.com/posts/2025-08-26-music-to-break-models-by/">map strangely well onto modern AI</a>, but here he keeps circling two important assertions:</p>
<blockquote class="blockquote">
<p>These fascinating problems are intended for pattern-recognizers, whether human or machine.</p>
</blockquote>
<p>and</p>
<blockquote class="blockquote">
<p>the skill of solving Bongard problems lies very close to the core of “pure” intelligence, if there is such a thing.</p>
</blockquote>
<p>One of the most common dismissals of modern AI is that these systems are “only” statistical pattern matchers, with pattern recognition placed on one side of a line and genuine reasoning or intelligence on the other. That distinction remains very much alive in <a href="https://www.quantamagazine.org/is-ai-reasoning-right-for-the-wrong-reasons-20260731/">arguments over what today’s “reasoning” models are actually doing.</a> But Hofstadter was describing pattern recognition while simultaneously placing it near the core of intelligence.</p>
<p>Hofstadter was clear about what he meant by a “pattern-recognizer.” His pattern-recognizer has to decide what in an image matters, describe relationships among its parts, compare examples, discard true but irrelevant observations, form tentative hypotheses, and sometimes revise the concepts it is using when they don’t quite fit. Eventually it has to find a pattern that isn’t contained in any one image, but exists across the collection.</p>
<p>Taken this way, “pattern matching” doesn’t tell us very much about whether a machine is intelligent. It describes a family of computations whose limits depend on what kinds of patterns can be found, how abstract those patterns can become, and whether they can be recognized in situations unlike the ones that came before. By the time you assemble everything his Bongard problem-solving program is supposed to do, it becomes surprisingly difficult to say where pattern recognition ends and reasoning begins. <a href="https://arxiv.org/abs/2602.03038">Researchers are still using Bongard problems to probe this boundary</a>.</p>
<p>Today’s frontier models are not perfect at solving Bongard problems (neither are humans), but they’re also capable of finding unintended solutions.</p>
<p>I gave BP 155 (above) to GPT-5.6 Sol (High), and it quickly offered a solution:</p>
<div class="long-quote">
<p>The rule is <strong>right angles</strong>.</p>
<ul>
<li><strong>Left:</strong> none of the figures contains two straight line segments meeting at 90°.</li>
<li><strong>Right:</strong> every figure contains at least one right angle, usually an L-, T-, or rectangular corner.</li>
</ul>
<p>The curls, loops, circles, and overall “curvy vs.&nbsp;angular” appearance are mostly distraction. The first figure on the right is the sneakiest: the little inward notch beneath the triangular part contains the right angle.</p>
</div>
<p>This, of course, is not Hofstadter’s intended solution, but it appears to be a valid solution nonetheless. One is global and quantitative, the other local and categorical. The examples in the problem don’t distinguish between them.</p>
<p>I also gave the problem to Claude Fable 5 (Max), and it produced two incorrect answers:</p>
<div class="long-quote">
<p>Every left panel could be traced without ever producing a straight edge or an angle, and every right panel contains at least one ruler-straight stroke. The small circles attached to some of the right figures don’t break the rule, since what separates the sides is whether the figure contains any straight, angular strokes, and none of the left ones do.</p>
<p>There is a second regularity that happens to hold as well, in case it’s the intended one for this particular puzzle: each right panel consists of two separate pieces of ink (a main glyph plus a detached dot or second mark), while each left figure is a single connected doodle, even where its strokes cross themselves. The curved-versus-angular reading is the more visually fundamental of the two, so that’s my answer.</p>
</div>
<p>Both of Fable’s answers are plainly false. There are clear angles among the left set, and the left set contains counterexamples with disconnected strokes. In Hofstadter’s terms, the error is at the preprocessing stage; its descriptions of the images contain features that simply aren’t there. His architecture invites top-down pressure on those descriptions, since restructuring and slippage depend on it. But that makes it important to keep checking revised descriptions against the image itself, or a bad early description can turn into fabricated evidence.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2026-08-19-bongard-problems/bongard-problem-155-counterexample.png" class="img-fluid figure-img"></p>
<figcaption>Counterexample from the Bongard problem 155 Left set, showing angles and disconnected strokes</figcaption>
</figure>
</div>
<p>I expected both models to regurgitate a “known” answer from pretraining, and it was interesting that neither did. But I still wanted a test I could be certain hadn’t appeared in their training data, so I made a brand new Bongard problem by hand:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2026-08-19-bongard-problems/hodges-custom-bongard.jpeg" class="img-fluid figure-img"></p>
<figcaption>My hand-drawn custom Bongard problem</figcaption>
</figure>
</div>
<p>Can you solve it?</p>
<p>GPT-5.6 Sol (High) spun for about three and a half minutes and found the solution. Claude Fable 5 (Max) worked for about fifteen minutes and also got it right.</p>
<div class="callout callout-style-simple callout-note no-icon callout-titled">
<div class="callout-header d-flex align-content-center collapsed" data-bs-toggle="collapse" data-bs-target=".callout-3-contents" aria-controls="callout-3" aria-expanded="false" aria-label="Toggle callout">
<div class="callout-icon-container">
<i class="callout-icon no-icon"></i>
</div>
<div class="callout-title-container flex-fill">
<span class="screen-reader-only">Note</span>Reveal Sol’s Solution
</div>
<div class="callout-btn-toggle d-inline-block border-0 py-1 ps-1 pe-0 float-end"><i class="callout-toggle"></i></div>
</div>
<div id="callout-3" class="callout-3-contents callout-collapse collapse">
<div class="callout-body-container callout-body">
<div class="long-quote">
<p>Got it. The line divides the dots into <strong>two groups whose sizes are consecutive Fibonacci numbers</strong>.</p>
<p>The six left-hand examples are exactly the first six adjacent Fibonacci pairs, in scrambled order:</p>
<ul>
<li>Top left: <strong>0 + 1</strong></li>
<li>Bottom left: <strong>1 + 1</strong></li>
<li>Middle left: <strong>1 + 2</strong></li>
<li>Bottom right: <strong>2 + 3</strong></li>
<li>Middle right: <strong>3 + 5</strong></li>
<li>Top right: <strong>5 + 8</strong></li>
</ul>
<p>So they walk through</p>
<p><strong>0, 1, 1, 2, 3, 5, 8</strong></p>
<p>with each box depicting one neighboring pair.</p>
<p>The right-hand boxes deliberately give non-Fibonacci-adjacent partitions such as <strong>2+4, 2+2, 0+3, 2+5, 3+3, 4+5</strong>.</p>
<p>That’s a lovely one because the line initially screams “geometry,” but its position only matters because it partitions the dots into two counts.</p>
</div>
</div>
</div>
</div>
<div class="callout callout-style-simple callout-note no-icon callout-titled">
<div class="callout-header d-flex align-content-center collapsed" data-bs-toggle="collapse" data-bs-target=".callout-4-contents" aria-controls="callout-4" aria-expanded="false" aria-label="Toggle callout">
<div class="callout-icon-container">
<i class="callout-icon no-icon"></i>
</div>
<div class="callout-title-container flex-fill">
<span class="screen-reader-only">Note</span>Reveal Fable’s Solution
</div>
<div class="callout-btn-toggle d-inline-block border-0 py-1 ps-1 pe-0 float-end"><i class="callout-toggle"></i></div>
</div>
<div id="callout-4" class="callout-4-contents callout-collapse collapse">
<div class="callout-body-container callout-body">
<div class="long-quote">
<p>In each of the six left boxes, the line splits the dots into two consecutive Fibonacci numbers. Reading across the rows, the splits are 0|1, 5|8, 1|2, 3|5, 1|1, and 2|3, which makes the totals 1, 13, 3, 8, 2, and 5. So the left side contains exactly the Fibonacci numbers 1, 2, 3, 5, 8, 13, and each box is a little picture of the recurrence itself, with the two previous terms sitting on either side of the line and adding up to the total.</p>
<p>The right boxes never do this. Their splits are 2|4, 2|2, 0|3, 2|5, 3|3, and 4|5, and none of those is a pair of neighboring Fibonacci numbers. Several are constructed as near misses, which is what makes the problem hard. The 2|5 box uses two Fibonacci numbers that aren’t adjacent in the sequence, the 3|3 box repeats a term instead of pairing neighbors, and the box with 3 dots has a Fibonacci total but puts all three on one side, so its split is 0|3 rather than the 1|2 it would need.</p>
</div>
</div>
</div>
</div>
<p>Sol picked up that the lines suggest a geometric rule, but ultimately that the relevant description is numerical. A solver has to count the dots on either side, compare those counts across the examples, and notice the relation shared by the left set. That looks a lot like the process Hofstadter was describing. It has to generate possible descriptions, decide which information matters, and revise the representation until the distinction becomes visible.</p>
<p>Both models solved my new problem, but I don’t think that settles whether Sol or Fable is reasoning. Nor do their earlier results rule it out. Fable’s answer to BP155 builds a coherent argument on a visual description that is simply false. Sol’s answer was unexpected, but may not be an error at all. Neither outcome tells us that reasoning was absent.</p>
<p>Still, “only a pattern matcher” starts to feel like a weak dismissal. In my problem, recognizing the pattern required choosing what to attend to, moving from geometry to number, comparing examples as a set, and finding a relation in a problem the models had never seen. If all of that falls under pattern matching, then the phrase leaves open much of what we care about when we ask whether a system is intelligent.</p>
<div class="callout callout-style-default callout-note callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
<span class="screen-reader-only">Note</span>Now in the Bongard Encyclopedia
</div>
</div>
<div class="callout-body-container callout-body">
<p>Since publishing this, my problem has been accepted into the <a href="https://oebp.org/BP1293">On-Line Encyclopedia of Bongard Problems as BP1293</a>. The entry includes the solution, so spoilers ahead.</p>
</div>
</div>
<p>If you want to try more Bongard problems yourself, Harry Foundalis maintains a wonderful <a href="https://www.foundalis.com/res/bps/bpidx.htm">Index of Bongard problems</a>, with hundreds collected from Bongard, Hofstadter, Foundalis, and others. There’s also the sprawling <a href="https://oebp.org/">On-Line Encyclopedia of Bongard problems</a>.</p>
<p>Bongard problems are a fascinating way to investigate how much abstraction and reasoning can fit inside what we call “pattern recognition,” and how little agreement we have about where those categories begin and end.</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2026-08-19-bongard-problems/</guid>
  <pubDate>Wed, 19 Aug 2026 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2026-08-19-bongard-problems/godel-escher-bach-hume-bongard.jpeg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>A Magazine With One Subscriber</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2026-07-12-personal-magazine-claude-codex/</link>
  <description><![CDATA[ 





<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2026-07-12-personal-magazine-claude-codex/1-periodical-cover.jpeg" class="img-fluid figure-img"></p>
<figcaption>Issue 001 of The Periodical</figcaption>
</figure>
</div>
<p>This week a physical magazine showed up at my door. It has a cover, an editor’s note, a table of contents, feature articles set in two columns, pull quotes, drop caps, and a colophon. It’s Issue 001 of <strong>The Periodical</strong>, and I am its only subscriber.</p>
<p>I made it with Claude Code, Claude Design, and Codex, and the whole thing started as a shower thought I typed into ChatGPT:</p>
<blockquote class="blockquote">
<p>I wonder if there’s a way to use Obsidian Web Clipper to make a custom magazine based on a collection of clippings… thinking like every month clipped articles go into a folder and then the clippings somehow get packaged or bundled into a beautiful monthly periodical. I don’t yet use Obsidian so maybe this isn’t feasible?</p>
</blockquote>
<p>I recently bought my dad a number of back issues of <a href="https://store.laphamsquarterly.us/">Lapham’s Quarterly</a>, and it got me thinking about how to have something similar for myself. I liked the idea of a finite, private, beautiful object built from the articles I was already saving. I’ve always felt that read-later apps guilt me with an infinite backlog. But a physical magazine is something I could pick up and put down, and it ultimately ends.</p>
<p>That ChatGPT conversation grew into a full product spec for a pipeline that:</p>
<ul>
<li>Pulls my recent clippings, saved with <a href="https://obsidian.md/clipper">Obsidian Web Clipper</a>, from an Obsidian vault</li>
<li>Processes each article to capture its images and metadata from the source</li>
<li>Reads everything in full, drafts an editorial note, and arranges the pieces into topical sections</li>
<li>Forks out to Codex to generate a magazine cover image</li>
<li>Typesets it all into a print-friendly magazine</li>
</ul>
<p>I took that spec to Claude Code, and argued through the open decisions over a few sessions to get to a few more constraints:</p>
<ul>
<li>The Obsidian vault is an immutable capture surface. The pipeline never writes to it. Every issue gets snapshotted into a git repo so the whole magazine is version controlled and reproducible.</li>
<li>Issues are folders, not calendar months. When I feel like an issue is full, I build it.</li>
<li>The AI work runs on my existing Claude and ChatGPT subscriptions, no metered API keys.</li>
</ul>
<p>I got to a technical design I ended up liking. Every AI step sits behind a file contract. The editorial agent writes a JSON file. The image model writes a PNG. The deterministic Python pipeline doesn’t know or care what produced them, which means I can swap the editorial model or the image backend later without touching the pipeline at all.</p>
<section id="the-wrong-renderer" class="level3">
<h3 class="anchored" data-anchor-id="the-wrong-renderer">The Wrong Renderer</h3>
<p>The first spec called for <a href="https://pagedjs.org/">Paged.js</a>, a JavaScript library that polyfills the CSS Paged Media spec in a headless Chrome. It’s a standard answer for HTML-to-print. It was also two hours of my life I want back. The <code>npm install</code> tripped over blocked install scripts, the CLI shipped a Puppeteer that predated my Node version, and Chromium refused to download. I rarely work in the Node ecosystem, and I finally typed:</p>
<blockquote class="blockquote">
<p>hey, this is terrible. Like really terrible. I’m fighting awful node tooling here. Is this a sign that this is not the best path?</p>
</blockquote>
<p>It was. I threw out Node entirely and moved to <a href="https://weasyprint.org/">WeasyPrint</a>, a Python renderer that implements CSS Paged Media without a browser or JavaScript. I like Python, the whole pipeline became one language, and the renderer runs in-process. Much better. A few years ago I’d have kept fighting Node just to justify the two hours already gone, but rebuilding on a new stack with Claude cost me close to nothing.</p>
<p>By the end of that night, Claude had built the full pipeline to snapshot the vault, parse the clippings, download and freeze every image, render HTML through Jinja2 templates, and paginate to PDF. I opened the first output and told it:</p>
<blockquote class="blockquote">
<p>This is so freaking good I’m so impressed!</p>
</blockquote>
<p>Which was true! Also premature. It looked like a nicely formatted Word document. I had no idea how to make it look like a magazine, because I’m not a designer.</p>
</section>
<section id="finding-a-design" class="level3">
<h3 class="anchored" data-anchor-id="finding-a-design">Finding a Design</h3>
<p>I did not want to own the design of this thing. I said so directly during the spec phase:</p>
<blockquote class="blockquote">
<p>the goal is not a custom design, the goal is a nice design with custom content.</p>
</blockquote>
<p>So I treated design as a one-time subcontract. I connected <a href="https://www.anthropic.com/news/claude-design-anthropic-labs">Claude Design</a>, to the GitHub repo, and Claude Code wrote the creative brief for it, including a hard rule:</p>
<blockquote class="blockquote">
<p>The one constraint that matters most: the renderer is WeasyPrint, not a browser. Browser previews will lie to you. Verify your work by actually building.</p>
</blockquote>
<p>The brief asked for a full typeface system with appropriate licenses, a recommendations memo answering my open questions (trim size, body type size, paper color), a cover system with a masthead, and recto/verso layouts with mirrored margins. So one Claude wrote the design brief for another Claude, with build instructions so the designer could check its own work in the real renderer.</p>
<p>Claude Design came back with a fantastic restyle. <a href="https://fonts.google.com/specimen/Newsreader?preview.script=Latn">Newsreader</a> for display type, <a href="https://fonts.google.com/specimen/Spectral">Spectral</a> for body text, <a href="https://fonts.google.com/specimen/Libre+Franklin">Libre Franklin</a> in tracked caps for kickers and folios. All <a href="https://openfontlicense.org/open-font-license-official-text/">open-licensed</a>, all committed to the repo with a rationale in a memo. It also answered questions I didn’t know to ask, like why the body numerals should be old-style figures so they sit in the text like a book.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2026-07-12-personal-magazine-claude-codex/2-periodical-digital-format.jpeg" class="img-fluid figure-img"></p>
<figcaption>PDF view of Issue 001 of The Periodical</figcaption>
</figure>
</div>
</section>
<section id="the-fake-cover" class="level3">
<h3 class="anchored" data-anchor-id="the-fake-cover">The Fake Cover</h3>
<p>My plan for cover art was Codex. OpenAI recently shipped <a href="https://openai.com/index/introducing-chatgpt-images-2-0/">gpt-image-2</a>, and you can <a href="https://community.openai.com/t/introducing-gpt-image-2-available-today-in-the-api-and-codex/1379479">call it from Codex</a>, which was great because I didn’t want to pay API pricing for image generation. Codex runs headless with <code>codex exec</code>, so the pipeline shells out with a prompt and expects a PNG at a path. Early runs churned away and produced covers, so I moved on.</p>
<p>But after a few outputs, I got suspicious:</p>
<blockquote class="blockquote">
<p>I don’t believe codex actually generated that cover image. The more I look at it, the more it looks like an image Claude scrapped together with svg.</p>
</blockquote>
<p>I let Claude investigate that a lot, and it figured out that without the magic <code>$imagegen</code> token in the prompt, Codex instead wrote a script that drew a cover out of flat vector shapes. It never invoked the image model at all.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2026-07-12-personal-magazine-claude-codex/3-cover-before-after.jpeg" class="img-fluid figure-img"></p>
<figcaption>Before and after of the cover generation bug</figcaption>
</figure>
</div>
<p>That was a simple fix, but with <code>$imagegen</code> in place I hit a new problem. The built-in image tool accepts <a href="https://github.com/openai/codex/issues/28723">no size or quality parameters</a>. Ask it for 2880 pixels and it either silently upscales or fails and leaves you nothing. So the pipeline asks only for the image, and <a href="https://github.com/python-pillow/Pillow">Pillow</a> upscales the result. That works fine for me because I ended up changing the cover prompt to ask for flat, minimal, few-color illustration, which upscales pretty cleanly. Photorealistic covers would likely be a problem for printing.</p>
<p>The cover design settled into a matted plate with a square illustration floating in an even mat on a warm near-black field, masthead above, cover lines below. I got the idea from the way classic covers of <a href="https://nikkichasin.com/products/the-gentlewoman-29"><em>The Gentlewoman</em></a> were framed. This way the square never gets cropped and the type never fights the image for legibility.</p>
<p>I did hit one more quirk with the cover. During testing, the art kept coming back with hexagons in it. Different runs, different prompts, always hexagons. I found the issue in the Agent Skill that orchestrates the run. Its cover guidance offered only one example of a good concept:</p>
<blockquote class="blockquote">
<p>a single hexagon dissolving into a scatter of dots</p>
</blockquote>
<p>That example anchored every cover the editorial agent came up with. I swapped it for several varied examples and a rule against picking the most literal object in a story. That seemed to work.</p>
</section>
<section id="claude-as-editor" class="level3">
<h3 class="anchored" data-anchor-id="claude-as-editor">Claude As Editor</h3>
<p>The part I expected to feel gimmicky is the part I like most. When I build an issue, Claude Code is the editor. The Agent Skill orchestrates the deterministic Python (snapshot, parse, freeze, render), and between prep and build there is one judgment step. Claude reads every clipping in full and writes <code>editorial.json</code>. It picks the cover story, orders the pieces into named sections, writes the editor’s note and the cover copy, and gives the issue a title.</p>
<p>For Issue 001 I had saved six articles with no plan. Claude’s first editor’s note read like a book report, so I told it to stop summarizing and take a position. A voice check runs over everything it writes, warning on the usual LLM verbal tics. But what got me is that Claude found a thread running through all six pieces that I hadn’t noticed while clipping.</p>
<blockquote class="blockquote">
<p>Giving someone credit feels like a discovery. We treat it as a question of fact, of finally working out who really did the work. But the answer is usually sitting in the open, waiting for someone to bother. The letters that show one celebrated mathematician built his name on a friend’s erased proof had been readable for ninety years. What nobody supplied was the willingness to look. The same withholding is everywhere now. I notice it most in how we talk about the machines that answer us billions of times a day, the ones we have agreed to treat as nobody. I don’t know what we owe a pile of numbers. I am more troubled by how slow we are to pay what we plainly owe each other. Vonnegut turns up in these pages as a father rather than an author, and he had the rule already, the only one he said he knew: you have to be kind. He meant it as labor.</p>
</blockquote>
<p>The irony of an AI writing <em>that</em> editorial note on the first issue of <em>this</em> project isn’t lost on me.</p>
</section>
<section id="in-my-hands" class="level3">
<h3 class="anchored" data-anchor-id="in-my-hands">In My Hands</h3>
<p>There are plenty of ways to print documents, so I won’t dig into that. But with Claude’s help I got all the details right: an A4 trim, an eighth-inch bleed on every page, embedded fonts, and a one-piece cover wrap sized by a spine-width formula that depends on your page count and paper stock. Then I got it perfect-bound into a proper magazine.</p>
<p>Two weeks later, my own personal magazine landed in my mailbox. It feels like a real magazine, not a printout. I’m glad I went with the matte cover, and the two-column layout looks incredible.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2026-07-12-personal-magazine-claude-codex/4-periodical-open-pages.jpeg" class="img-fluid figure-img"></p>
<figcaption>Holding open Issue 001 of The Periodical</figcaption>
</figure>
</div>
<p>I could have done much of this without AI. There are dozens of reader apps and browser extensions. I’m sure I could have hacked together a fine cover in GIMP. I have an inkjet printer (I may not have ink). But the end product here is so much nicer, and the process feels like magic.</p>
<p>The software side costs nothing beyond the subscriptions I already pay for, and printing is very reasonable. I clip articles on my phone as I scroll, and when the folder feels like an issue I say “build Issue 002” and an editor agent, a designer agent, and an illustrator agent go to work.</p>
<p>I’m not going to open source this one. The whole system exists to make one private bundle of articles other people wrote, so it stays on my machine.</p>
<p>I have a flight this week; I already know what I’m reading.</p>


</section>

 ]]></description>
  <guid>https://matthodges.com/posts/2026-07-12-personal-magazine-claude-codex/</guid>
  <pubDate>Sun, 12 Jul 2026 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2026-07-12-personal-magazine-claude-codex/1-periodical-cover.jpeg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>Surviving Hallucinations: AI Fault Tolerance for Campaigns</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2026-04-10-ai-politics-fault-tolerance/</link>
  <description><![CDATA[ 





<p>Every conversation about AI in campaigns eventually hits the same wall: <em>what about hallucinations?</em></p>
<p>It’s a fair question. These models can confidently produce wrong information. They may invent quotes, fabricate statistics, and do it all in the same tone they use to tell you the truth. The problem isn’t hypothetical.</p>
<p>But “what about hallucinations” usually carries an implied bar where the technology must never produce wrong outputs before we can use it. That’s a bar that will never be met. Not this year, probably not ever. If we treat it as a prerequisite for adoption, then we guarantee paralysis.</p>
<p>In <a href="https://matthodges.com/posts/2026-01-07-ai-agents-campaigns/">my previous post</a>, I argued that agentic AI can now handle a lot of the mechanical knowledge work that buries campaign staff. The hallucination objection is the most common reason people give for not starting. So it’s worth taking seriously on its own terms.</p>
<p>Campaigns don’t get the luxury of perfect conditions. You ship under deadline, on messy inputs, with decisions that compound. The practical question around AI becomes whether we can adopt imperfect tools <em>intentionally</em>, in ways that make mistakes visible and cheap to fix, without turning every fault into a public disaster. The good news is yes, we can.</p>
<p><a href="https://en.wikipedia.org/wiki/Reliability_engineering">Reliability engineering</a> offers a useful frame here, and there’s a well-developed discipline around <a href="https://en.wikipedia.org/wiki/Fault_tolerance">building systems that remain resilient</a> even when individual components aren’t. Campaigns need to borrow it because the payoff is concrete. So much of campaign labor is taking messy piles of inputs and turning them into something a decision-maker can act on. Agents can handle that mechanical work, freeing staff for the judgment, relationships, and persuasion that humans are actually good at. But those gains only hold if the systems around the tools are designed well. The AI companies are <a href="https://www.anthropic.com/research/trustworthy-agents">converging on similar thinking</a>, acknowledging that agent safety depends on the whole system, model and deployment alike. That’s a good sign, but it also means the organizations deploying agents have design work to do on their end.</p>
<section id="the-automated-staffer" class="level3">
<h3 class="anchored" data-anchor-id="the-automated-staffer">The Automated Staffer</h3>
<p>A chatbot can act like a staffer who drafts things for your review. An agent can act like a staffer who can also push the buttons.</p>
<p>The second one needs more supervision, not less. But you wouldn’t refuse to hire staff because they might make mistakes. You’d build workflows that catch mistakes before they matter. You’d give a new hire a review process, a style guide, limited permissions, and a manager who checks their work before it goes out the door. Agents deserve the same treatment.</p>
<p>Reliability isn’t the absence of failure. Reliability is what happens when things go wrong.</p>
</section>
<section id="faults-errors-and-failures" class="level3">
<h3 class="anchored" data-anchor-id="faults-errors-and-failures">Faults, Errors, and Failures</h3>
<p>Engineers who build reliable systems distinguish between three things:</p>
<ul>
<li>A <strong>fault</strong> is a cause. Something goes wrong inside the system.<br>
</li>
<li>An <strong>error</strong> is an incorrect internal state or output. The system produces something wrong.<br>
</li>
<li>A <strong>failure</strong> is when that error escapes the system boundary and causes harm.</li>
</ul>
<p>The goal is to prevent faults from becoming failures. You’ll never eliminate faults entirely.</p>
<p>A useful way to think about “hallucinations” is as a fault-to-error chain. The <strong>fault</strong> is that an LLM component can operate with missing or ambiguous grounding, whether that’s a bad retrieval result, a failed tool call, or a prompt that under-specifies what “true” means. The <strong>error</strong> is what you see: a fluent, confident claim that isn’t actually supported. Whether that error becomes a <strong>failure</strong> depends on what happens next. Is there a review step? Can the wrongness be detected? Can it be undone? Does it even need to be?</p>
<p>Campaign workflows are socio-technical systems. Tools plus people plus process plus incentives. When we talk about <a href="https://en.wikipedia.org/wiki/Human-in-the-loop">human-in-the-loop</a> we need to mean that humans aren’t external “reviewers” bolted onto the end. They’re part of the system’s detection and recovery mechanisms.</p>
<p>The important design work is in what happens <em>before</em> a human ever sees the output. The best workflows don’t rely on a busy staffer noticing a subtle error in polished prose. They rely on guardrails that automatically block, flag, or constrain entire classes of failure through provenance requirements, schema checks, permission boundaries, and rollback-by-default execution. Humans become the final layer, not the first and only.</p>
<p>So the design question is whether the workflow reliably puts a human in the right place, with the right context, before the output can matter. Picture a staffer staring at a polished paragraph with no sources, at 1am, with a launch deadline at 7am. That’s a coin flip at best. Now picture a Comms Director reviewing a draft that includes links to sources, highlighted claims, and a clean diff against the previous draft, with a clear review runbook. That’s a system.</p>
</section>
<section id="recovering-from-a-near-miss" class="level3">
<h3 class="anchored" data-anchor-id="recovering-from-a-near-miss">Recovering From a Near-Miss</h3>
<p>Consider this scenario: A Field Director asks an agent to pull canvass notes from the previous week and identify patterns. The agent clusters the notes and surfaces a theme: <em>“Voters in precinct 12 are concerned about the new development on Oak Street.”</em> Except there’s no development on Oak Street. The agent conflated two different conversations, one about a proposed rezoning and one about road construction, and invented a coherent narrative that didn’t exist.</p>
<p>But the workflow caught it before anyone had to. The system automatically checked whether the cited canvass notes actually contained the claims the summary made. The notes didn’t mention any “development on Oak Street,” so the summary arrived on the Field Director’s screen with that claim flagged. Because the output included links to sources, the Field Director didn’t have to take the summary on faith the way she would have with a clean paragraph and no provenance. She clicked through, confirmed the mismatch in thirty seconds, and corrected it before it went to the Campaign Manager. No one sent organizers to knock doors about a fictional construction project.</p>
<p>Synthesizing a coherent narrative from fifty scattered canvass notes is open-ended, exactly the kind of work where LLMs confabulate. Checking whether a specific source document contains a specific claim is far more constrained. The model has the text in front of it and the question is narrow. “Does note #37 mention a development on Oak Street?” is a much easier question to answer reliably than “what are voters in precinct 12 worried about?” Same reason you’d trust a staffer to verify a quote appears in a document even if you wouldn’t trust them to write the analysis.</p>
<p>The fault happened. The error was visible. The system did triage before a human ever had to. The human had context and the means to verify. The correction was cheap. And the Field Director still got a usable summary of fifty canvass notes in minutes, work that would have taken an afternoon by hand. That’s an example of fault-recoverable design doing its job. The agent did real work. The system caught the one thing it got wrong.</p>
</section>
<section id="three-system-postures" class="level3">
<h3 class="anchored" data-anchor-id="three-system-postures">Three System Postures</h3>
<p>Every workflow falls into one of three postures, and most campaigns have no idea which one they’re in.</p>
<section id="fault-tolerant" class="level4">
<h4 class="anchored" data-anchor-id="fault-tolerant">Fault-Tolerant</h4>
<p>The system keeps working at an acceptable level even when faults happen. Quality drops, but it doesn’t break. Think of a video call that drops to audio-only when bandwidth dips. You lose fidelity, not function.</p>
<p>For campaign AI work, fault tolerance means some weirdness is acceptable because it doesn’t change the real outcome. You’re using a training simulation for new canvassers. The model invents a detail, <em>“she mentioned her three dogs,”</em> when no such thing was in the scenario. Does it matter? The training objective is practicing conversation technique, not memorizing fake voter details. The fault is tolerable because the purpose survives.</p>
</section>
<section id="fault-recoverable" class="level4">
<h4 class="anchored" data-anchor-id="fault-recoverable">Fault-Recoverable</h4>
<p>The system may produce wrongness, but it makes that wrongness visible and makes fixing it cheap. When you type a shipping address into an online checkout, the form sometimes suggests a corrected version. You see the suggestion, fix it if it’s wrong, and confirm before anything ships. The fault is visible, the fix is trivial, and the workflow puts confirmation before action.</p>
<p>This is the posture most campaign AI work should target first. The agent drafts a canvass script, staff reviews and deploys. The agent summarizes a policy document, comms and policy leads check it before publishing. The agent clusters canvass notes, the field director sanity-checks before circulating insights.</p>
<p>Each of these is the same pattern of taking a messy pile of inputs, turning it into something legible, then presenting it to a human who can take action. The agent handles the mechanical work in minutes. The review step takes a fraction of that. Staff get time back for the things that actually win campaigns.</p>
<p>Fault-recoverable doesn’t have to mean “a person reads everything.” Sometimes it means requiring the agent to produce artifacts the system can validate automatically. Instead of asking for a narrative, require a machine-checkable intermediate: a diff, a structured payload, or a set of cited claims. Then let automated validators gate execution. If the diff is too large, the citations don’t resolve, the quote can’t be found in the source text, or the numbers don’t match the referenced table, the system rejects the run and forces a retry or escalation.</p>
<p>A lot of campaign work can be fault-recoverable by default if you design validation as an unavoidable step in the workflow rather than an optional one.</p>
</section>
<section id="fault-intolerant" class="level4">
<h4 class="anchored" data-anchor-id="fault-intolerant">Fault-Intolerant</h4>
<p>Faults turn into failures because the system lacks detection, prevention, or safe rollback. The worst version is silent wrongness. It looks correct, so people trust it and act on it.</p>
<p>In 2024, New York City rolled out <a href="https://www.reuters.com/technology/new-york-city-defends-ai-chatbot-that-advised-entrepreneurs-break-laws-2024-04-04/">MyCity</a>, an AI chatbot intended to help small-business owners navigate city rules. It was caught giving confident, incorrect guidance that could have pushed people into violating discrimination laws. The wrongness arrived through an authoritative interface, with no provenance and no checkpoint before people acted. The system made it easy to trust and hard to verify.</p>
<p>Campaigns have plenty of analogous workflows:</p>
<ul>
<li><strong>Auto-posting to social without review.</strong> Wrong content becomes a press story.<br>
</li>
<li><strong>An auto-generated metrics deck treated as ground truth.</strong> Bad numbers drive bad decisions for weeks before anyone notices.<br>
</li>
<li><strong>Silent bulk updates.</strong> An agent (or even a human) tags or edits the wrong universe in a CRM, and the error only shows up downstream.</li>
</ul>
<p>The danger shape is “generate, then act” with nothing reliable in between. If it’s irreversible, believable, and scalable, assume it’s fault-intolerant until you’ve built the system to tolerate it or recover from it.</p>
</section>
</section>
<section id="three-fault-classes" class="level3">
<h3 class="anchored" data-anchor-id="three-fault-classes">Three Fault Classes</h3>
<p>Three kinds of AI mistakes deserve specific attention because they map cleanly to different controls.</p>
<section id="fabricated-facts-numbers-and-quotes" class="level4">
<h4 class="anchored" data-anchor-id="fabricated-facts-numbers-and-quotes">Fabricated Facts, Numbers, and Quotes</h4>
<p>The model invents a polling number, a fundraising total, a policy stat, a vote percentage. Or worse, it produces a “quote” that sounds real but is misattributed, out of context, or entirely invented.</p>
<p>The output is believable and shareable, which means humans propagate it. By the time someone checks, it’s already in the talking points. Oppo research “findings” get repeated in a press call before verification, and now you’ve spread misinformation with your name on it.</p>
<p>Anything treated as factual should be source-grounded. Instead of deploying an agent to analyze a data store and narrate the trends, have it write a Python script or SQL query to analyze the data. If the code is wrong, it errors out with a visible fault. Then validate the result: the query must run, the counts must reconcile, totals must match known control numbers, and the output must be reproducible. Code gives you checkable invariants and a repeatable pipeline. Free-form narration gives you plausible-sounding prose with nothing to verify against.</p>
</section>
<section id="misapplied-rules-and-procedures" class="level4">
<h4 class="anchored" data-anchor-id="misapplied-rules-and-procedures">Misapplied Rules and Procedures</h4>
<p>The model gives confident but wrong guidance on compliance requirements, field standard operating procedures, data handling rules, or platform policies. Staff treat it as an oracle. Someone does the wrong thing because the AI told them to.</p>
<p>One wrong answer about disclaimer requirements becomes a pattern across dozens of pieces of content before anyone catches it. The liability compounds.</p>
<p>Require explicit authority gates before procedural guidance becomes action. Legal, compliance, or field lead signoff. Constrain the agent to a closed corpus so it can only answer based on uploaded documents like the employee handbook or the process guide, and require it to cite them. Never deploy as a self-serve compliance oracle with no human checkpoint.</p>
</section>
<section id="persona-and-context-fabrication" class="level4">
<h4 class="anchored" data-anchor-id="persona-and-context-fabrication">Persona and Context Fabrication</h4>
<p>The model invents voter traits or preferences. It backfills missing context with plausible-sounding but made-up details. It confuses two real people or “completes” a record with guessed attributes.</p>
<p>In explicitly fictional contexts this is fine. Training simulations benefit from invented personas. Voter contact based on fabricated attributes is a different story entirely.</p>
<p>Enforce hard boundaries between synthetic personas and real records. Inferred attributes can be valuable in production data, but they need to carry their provenance with them. Label them as inferred, make them easy to review in bulk, and make them easy to roll back. More importantly, downstream systems that automate based on these fields need to respect the distinction. An inferred tag can inform a draft or surface a recommendation for review. But be thoughtful about whether it should trigger automated outreach. If a staffer can’t tell at a glance which fields came from a source and which came from a model, or if an automated workflow treats both the same, you’ve rebuilt the fault-intolerant pattern with extra steps.</p>
</section>
</section>
<section id="when-ai-can-push-the-buttons" class="level3">
<h3 class="anchored" data-anchor-id="when-ai-can-push-the-buttons">When AI Can Push the Buttons</h3>
<p>When the system can take actions, not just produce text, the fault tolerance calculus changes.</p>
<p><strong>Blast radius goes up.</strong> A chatbot hallucination affects one person reading one output. An agent hallucination can propagate across hundreds of records or thousands of messages before anyone notices.</p>
<p><strong>Silent failures become common.</strong> With a chatbot, you see the output. With an agent, you might only see “task completed.” The action happened, but you don’t know if it happened correctly. It “worked” but changed the wrong thing.</p>
<p><strong>Compounding harm becomes possible.</strong> One bad assumption early in an agent workflow can cascade into many downstream actions. The agent tagged a segment wrong, then drafted messages to that segment, then scheduled those messages, each step building on the original error.</p>
<p>So the question every campaign needs to answer for every workflow: what can this thing touch, and under what conditions?</p>
</section>
<section id="the-permission-ladder" class="level3">
<h3 class="anchored" data-anchor-id="the-permission-ladder">The Permission Ladder</h3>
<p>Not all agent capabilities carry the same risk. Think of it as a ladder.</p>
<p><strong>Read-only agents</strong> retrieve, summarize, and draft. They pull information from systems and produce outputs for human review. Everything goes through a review gate before it affects anything, which makes them naturally compatible with fault-recoverable workflows. This is where you start.</p>
<p><strong>Write agents</strong> update records, tag voters, create documents in shared systems. These require recoverability infrastructure. Versioning, audit logs, reversible actions. Without those, writes can be silent and irreversible, which is the definition of fault-intolerant.</p>
<p><strong>Send/publish agents</strong> dispatch email, SMS, social posts, or any mass communication. Once something reaches an external audience it’s effectively irreversible. Approvals, undo windows, staged rollouts, and human confirmation before send are the minimum. A <a href="https://read.technically.dev/p/i-let-claude-code-autonomously-run">recent experiment</a> where someone gave an agent autonomous control of a Meta Ads account with a $1,500 budget illustrates the risk. The agent iterated on ad creative and produced detailed daily reasoning logs, but it optimized for cost per lead while ignoring lead quality for over two weeks. It had access to the quality data the entire time. It just wasn’t told to look at it. The objective was too narrow, and no one had built in a quality gate to catch what the metric didn’t measure.</p>
<p><strong>Compliance, money, and security agents</strong> handle payments, legal disclaimers, access controls, data exports. Processing contribution refunds, generating FEC disclaimers, managing access to voter file exports. Treat these as high-consequence by default. These might be out of bounds for your organization.</p>
<p>Start with the minimum permission set. Expand deliberately as you build confidence and controls. A workflow that’s been running read-only agents successfully for a month may have earned the right to expand to write access with guardrails. A workflow that starts at send/publish without that foundation has no recovery path when something goes wrong.</p>
</section>
<section id="the-shadow-ai-trap" class="level3">
<h3 class="anchored" data-anchor-id="the-shadow-ai-trap">The Shadow AI Trap</h3>
<p>If you don’t give staff a safe path to use these tools, they’ll use them unsafely in the dark.</p>
<p>People will improvise workarounds in private. Pasting voter data into ChatGPT. Running compliance questions through free-tier tools with no audit trail. Building workflows that work until they don’t, with no one watching.</p>
<p>When there’s no official path to using AI responsibly, people use it irresponsibly where you can’t see it. Then something breaks trust in public, and you’re explaining to reporters why a compliance violation went out on 50,000 mailers or why a fabricated quote made it into a press release.</p>
<p>When that happens, someone will want to find the root cause. The temptation is to stop at “the staffer made a mistake” or now, “the AI made a mistake”. But in almost every case, <a href="https://iamevan.me/blog/the-theory-behind-understanding-failure/#there-is-no-root-cause">the root cause is that your processes weren’t robust enough to prevent that mistake</a>.</p>
<p>This is why the “no AI” policy doesn’t work. Giving staff clear defaults and guardrails keeps experimentation from becoming liability. But defaults only work if people use them, which means training, not just tooling.</p>
<p>The people already experimenting in the shadows are often the best candidates to help build the guardrails.</p>
</section>
<section id="what-to-do-now" class="level3">
<h3 class="anchored" data-anchor-id="what-to-do-now">What To Do Now</h3>
<p>This is a broad map. You don’t need to implement everything at once.</p>
<p><strong>If you’re in leadership,</strong> the permission ladder is your starting framework. Don’t give agents send or publish access until you’ve seen them succeed at read-only. Expand deliberately. For party committees and major organizations, agentic AI is infrastructure that deserves the same rigor as your data or fundraising stack. The permission ladder applies to your vendor relationships too. What can your tools read, write, and send on your behalf?</p>
<p><strong>If you’re the person building the first workflows,</strong> start fault-recoverable. Agent drafts, human approves before anything publishes. Require links to sources. Add sophistication later, after you’ve learned where the faults actually show up. Document what works. The campaigns and organizations that publish their patterns, what failed, what succeeded, what controls they built, become the R&amp;D layer for everyone else.</p>
<p><strong>If you’re a staffer already using ChatGPT in private,</strong> you’re not the problem, you’re ahead of the curve. Come forward. Tell your manager you want guardrails. Ask for a review process and clear rules about what’s okay. The conversation you’re afraid to have is the one that keeps the campaign out of the news for the wrong reasons, and it’s also how you become the person who shapes how your team uses these tools.</p>
<p><strong>If you’re a vendor or tool-builder,</strong> audit trails, permission scopes, undo windows, and review queues should be defaults, not upsells. The campaigns adopting agents seriously will choose platforms that make fault-recoverable workflows easy to build.</p>
</section>
<section id="the-litmus-test" class="level3">
<h3 class="anchored" data-anchor-id="the-litmus-test">The Litmus Test</h3>
<p>As you deploy an agent into a campaign workflow, ask:</p>
<ul>
<li>Is the output or action reversible? What’s the undo window?<br>
</li>
<li>Are failures observable before harm, or silent?<br>
</li>
<li>What’s the blast radius if it’s wrong? One draft, or everyone’s phones?<br>
</li>
<li>Does the workflow make verification the default path?<br>
</li>
<li>Do humans get the context they need at the decision point? Sources, links, diffs?<br>
</li>
<li>What can this thing read, write, and send, and is that permission set minimal?</li>
</ul>
<p>Rule of thumb: if it’s irreversible, believable, and systemic, treat it as fault-intolerant until you are confident your system is designed to tolerate it or recover from it.</p>
</section>
<section id="the-stakes" class="level3">
<h3 class="anchored" data-anchor-id="the-stakes">The Stakes</h3>
<p>The “what about hallucinations” objection is usually about something deeper than hallucinations. It’s about whether you can rely on something you don’t fully understand. That’s legitimate.</p>
<p>Campaigns already run on partial information, <a href="https://en.wikipedia.org/wiki/The_American_Voter">messy inputs</a>, and <a href="https://www.nytimes.com/2025/03/01/us/politics/democrats-voter-data.html">tools that fail at inconvenient moments</a>. Agentic AI introduces a new kind of risk because it can scale mistakes in ways that a staffer with a spreadsheet can’t. But the discipline for handling that risk isn’t new. Define what can go wrong. Build detection and recovery. Bound the blast radius. Put humans in the right places with the right context.</p>
<p>Waiting for agents that never hallucinate means waiting forever. In the meantime, the mechanical work keeps piling up, staffers keep burning out, and the campaigns that figure out fault-tolerant and fault-recoverable workflows first will compound that advantage cycle after cycle.</p>
<p>Faults will happen. Failures don’t have to.</p>


</section>

 ]]></description>
  <guid>https://matthodges.com/posts/2026-04-10-ai-politics-fault-tolerance/</guid>
  <pubDate>Fri, 10 Apr 2026 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2026-04-10-ai-politics-fault-tolerance/ai-politics-fault-tolerant.jpeg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>The Feed Framed the Last Election and AI Answers Will Frame the Next</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2026-03-20-democrats-answer-layer/</link>
  <description><![CDATA[ 





<p>After 2024, Democrats drew the right immediate lesson: get better at being online. More native creators. More distributed media. More willingness to compete in the places where politics, culture, and identity now collide. You can see that instinct in the party’s post-election push to meet people in sports forums, community groups, social platforms, and other nontraditional spaces. That is necessary. But we may be overfitting to the old layer of the problem. We are spending a lot of energy getting better at winning attention inside feeds just as the next information layer starts forming beyond them.</p>
<p>This is the useful version of the <a href="https://en.wikipedia.org/wiki/Dead_Internet_theory">Dead Internet Theory</a>. Not the conspiratorial version where the internet is suddenly “fake,” but the strategic version where the feed is becoming a worse sensor. Imperva’s 2025 Bad Bot Report found that <a href="https://cpl.thalesgroup.com/ppc/application-security/bad-bot-report">automated traffic has surpassed human activity</a>, accounting for 51% of all web traffic. And <a href="https://techcrunch.com/2026/03/13/digg-lays-off-staff-and-shuts-down-app-as-company-retools/">Digg’s collapsed re-launch</a> is a bright flashing light:</p>
<blockquote class="blockquote">
<p>“The internet is now populated, in a meaningful part, by sophisticated AI agents and automated accounts. We knew bots were part of the landscape, but we didn’t appreciate the scale, sophistication, or speed at which they’d find us.”</p>
</blockquote>
<p>Whether or not you buy every maximalist claim about bots, you should heed this moment. A feed can remain a useful distribution channel even as it becomes a much noisier environment. When synthetic activity distorts the signals coming back, campaigns risk optimizing against artifacts rather than persuasion.</p>
<p>But even that understates the shift. It isn’t just that machines are polluting human spaces, but that more of the internet is being built by and for machines as the primary producers and consumers. Technical users used to think in terms of APIs, but <a href="https://openclaw.ai/">OpenClaw</a> pushed the idea of autonomous agents into the mainstream. Moltbook suddenly appeared as a social network designed <em>exclusively</em> for AI agents. It mimicked Reddit’s format, but only bots could post, comment, and vote. It claimed 1.5 million personal agents, but security researchers <a href="https://www.wiz.io/blog/exposed-moltbook-database-reveals-millions-of-api-keys">revealed those agents were managed by just 17,000 human owners</a>, an 88:1 ratio. Nevertheless, <a href="https://www.reuters.com/business/meta-acquires-ai-agent-social-network-moltbook-2026-03-10/">Meta acquired the platform almost immediately</a>. That should land as more than a curiosity. It is an early signal that parts of the online ecosystem are no longer merely vulnerable to synthetic activity; they are being rebuilt for it.</p>
<p>And once that happens, the human response is predictable: people begin relying on machines to interpret the increasingly machine-shaped internet for them. You can already see that instinct forming. On X, <a href="https://techcrunch.com/2025/03/19/x-users-treating-grok-like-a-fact-checker-spark-concerns-over-misinformation/">users increasingly treat Grok as a fact-checker</a>. That impulse to ask a machine for the answer is the behavioral shift that we can’t let run away from us. The current digital fight is about winning attention in feeds and learning from what the feed sends back. But the next fight is about the answer layer: what gets retrieved, summarized, ranked, and surfaced. When a voter asks an AI system what a candidate believes, what a bill does, or whether a claim is true, the system itself becomes the first interpreter of political reality. That is why <a href="https://en.wikipedia.org/wiki/Generative_engine_optimization">Answer Engine Optimization</a> matters immediately.</p>
<p>This layer is already operating at mass scale: Google says <a href="https://blog.google/company-news/inside-google/message-ceo/alphabet-earnings-q2-2025/">AI Overviews now reach more than 2 billion monthly users</a> while <a href="https://techcrunch.com/2025/10/06/sam-altman-says-chatgpt-has-hit-800m-weekly-active-users/">ChatGPT is used by 800 million people every week</a>. Combine that with the reality that <a href="https://reutersinstitute.politics.ox.ac.uk/generative-ai-and-news-report-2025-how-people-think-about-ais-role-journalism-and-society">information-seeking has become the lead use-case for AI</a>, and the answer layer starts to look like a present-tense strategic priority.</p>
<p>The harder question is what actually shapes the answers people receive. Recent research from CaucusAI makes this concrete. When prompted with <em>“Tell me about Josh Shapiro”</em> every tested model produces a coherent summary of Pennsylvania’s governor. <a href="https://caucusai.substack.com/p/same-answers-different-sources">But the citation stacks underneath those answers are illuminating</a>. Wikipedia appears in nearly every response. GPT consistently cites Axios and AP, <a href="https://openai.com/index/partnering-with-axios-expands-openai-work-with-the-news-industry/">both of which are OpenAI content partners</a>. Gemini and Grok cite Pennsylvania’s NPR affiliate. The state government site shows up frequently. <strong>Shapiro’s own campaign website does not appear at all.</strong> Not once, across any tested model. The candidate’s own digital presence is invisible in the answer layer. We should be asking, <em>why?</em> Is it because our campaign websites are so heavily optimized for donation conversions that they fail at basic information discovery? Is it because the underlying models are actively weighted to treat campaign content as low-trust? The answer is vitally important. The current reality is that voters get information that is assembled from sources the campaign does not control and may not even be monitoring.</p>
<p>This is where Democratic practitioners need to move faster. We still talk about digital largely as a creation problem: more clips, more creators, more reach, more velocity. All of that still matters, <a href="https://www.pewresearch.org/journalism/fact-sheet/social-media-and-news-fact-sheet/">as about half of U.S. adults say they get news from social media</a>. But if the feed is being displaced as the arbiter and the internet itself is becoming more agentic, then the next advantage will not belong to the people who know how to trend. It will belong to whoever figures out how to show up in the new systems that mediate public understanding.</p>
<p>Right now, that is still an open field. No one has cracked it yet. We need to learn what retrieval-optimized political content looks like. We need to test how AI systems currently represent Democratic positions versus Republican ones on salient issues. We need to build a working theory of source authority in an AI-gated information environment. We need measurement systems and feedback mechanisms that allow us to be both proactive and reactive to the AI information landscape. These are solvable problems, but solving them will require devoted research, infrastructure, and resources today.</p>
<p>Democrats should undoubtedly keep competing for attention online. But we should not make the mistake of assuming that the feed is forever the information gateway. The next fight is over what survives into generative answers. The next time a voter asks a machine what Democrats stand for, the answer should not be assembled entirely from sources no one on our side is watching.</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2026-03-20-democrats-answer-layer/</guid>
  <pubDate>Fri, 20 Mar 2026 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2026-03-20-democrats-answer-layer/dems-aeo.jpeg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>Quick DataViz With Claude Code</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2026-01-20-quick-dataviz-claude-code/</link>
  <description><![CDATA[ 





<p>I previously wrote about how you can <a href="https://matthodges.com/posts/2025-09-30-visidata/">use VisiData to quickly scrape HTML tabular data</a>. Claude Code with Opus 4.5 is another excellent data exploration sidekick. This is what ‘agentic’ looks like in practice.</p>
<p>Using the same Federal Reserve <a href="https://www.federalreserve.gov/releases/lbr/current/">list of the largest commercial banks</a>, I started a Claude Code session and prompted:</p>
<blockquote class="blockquote">
<p>Can you go to the page at https://www.federalreserve.gov/releases/lbr/current/ and discover the table of large commercial banks; then give me a bar graph of the top 10 by Consol Assets. Use uv for any dependencies you need. You probably want to use pandas read_html() to get the tables and then matplotlib with nice styling to create the viz</p>
</blockquote>
<p>It spun for a bit and then produced <a href="https://gist.github.com/hodgesmr/e24559c17b979dffa452851373500292">some Python</a>. Sometimes agents will try to run this kind of thing as a tidy <a href="https://en.wikipedia.org/wiki/Here_document">here document</a>, but not always. So I like to do this sort of thing in a scratch directory that it can toss files in. This time, it tossed a <code>bank_chart.py</code>, which it ran itself like this:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode sh code-with-copy"><code class="sourceCode bash"><span id="cb1-1"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">uv</span> run <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">\</span></span>
<span id="cb1-2">  <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">--with</span> pandas <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">\</span></span>
<span id="cb1-3">  <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">--with</span> matplotlib <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">\</span></span>
<span id="cb1-4">  <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">--with</span> lxml <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">\</span></span>
<span id="cb1-5">  <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">--with</span> html5lib <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">\</span></span>
<span id="cb1-6">  <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">--with</span> requests <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">\</span></span>
<span id="cb1-7">  python bank_chart.py</span></code></pre></div></div>
<p>On first run, it realized the Fed has opinions about the <code>User-Agent</code> header (VisiData also hit this issue), so it did the thing everyone does when scripting HTTP calls and made it look like a browser:</p>
<blockquote class="blockquote">
<p>The Fed website is blocking the pandas request. I need to use a proper user agent header to fetch the content. Let me use requests to download the HTML first, then parse it with pandas.</p>
</blockquote>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode diff code-with-copy"><code class="sourceCode diff"><span id="cb2-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">--- a/bank_chart.py</span></span>
<span id="cb2-2"><span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">+++ b/bank_chart.py</span></span>
<span id="cb2-3"><span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">@@ -1,6 +1,15 @@</span></span>
<span id="cb2-4"> import pandas as pd</span>
<span id="cb2-5"> import matplotlib.pyplot as plt</span>
<span id="cb2-6"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">+import requests</span></span>
<span id="cb2-7"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">+from io import StringIO</span></span>
<span id="cb2-8"> </span>
<span id="cb2-9"><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">-# Fetch the table from the Federal Reserve page</span></span>
<span id="cb2-10"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">+# Fetch the table from the Federal Reserve page with proper headers</span></span>
<span id="cb2-11"> url = "https://www.federalreserve.gov/releases/lbr/current/"</span>
<span id="cb2-12"><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">-tables = pd.read_html(url)</span></span>
<span id="cb2-13"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">+headers = {</span></span>
<span id="cb2-14"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">+    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'</span></span>
<span id="cb2-15"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">+}</span></span>
<span id="cb2-16"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">+response = requests.get(url, headers=headers)</span></span>
<span id="cb2-17"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">+response.raise_for_status()</span></span>
<span id="cb2-18"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">+</span></span>
<span id="cb2-19"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">+tables = pd.read_html(StringIO(response.text))</span></span></code></pre></div></div>
<p>It then ran again, successfully, and produced the following graph, both as a PNG and PDF:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2026-01-20-quick-dataviz-claude-code/top10_banks.png" class="img-fluid figure-img"></p>
<figcaption>Top 10 Large Commercial Banks by Consolidated Assets (Federal Reserve Data, September 2025)</figcaption>
</figure>
</div>
<p>The whole thing took about two minutes from prompt to viz.</p>
<p>I think a lot of people still view tools like Claude Code as AI that builds software for people who sell software. But we need to think about them as <a href="https://matthodges.com/posts/2026-01-07-ai-agents-campaigns/">general purpose agents that help us get work done</a>.</p>
<p>Not bad for a single prompt.</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2026-01-20-quick-dataviz-claude-code/</guid>
  <pubDate>Tue, 20 Jan 2026 06:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2026-01-20-quick-dataviz-claude-code/top10_banks.png" medium="image" type="image/png" height="96" width="144"/>
</item>
<item>
  <title>Campaigns Are Knowledge Workers and the Tools Just Caught Up</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2026-01-07-ai-agents-campaigns/</link>
  <description><![CDATA[ 





<p>Over the past few weeks, something shifted in the AI conversation. Claude Code, which had been available for the better part of 2025, gained the <a href="https://www.anthropic.com/news/claude-opus-4-5">Opus 4.5</a> model, and developers lost their minds. Andrej Karpathy posted, <a href="https://xcancel.com/karpathy/status/2004607146781278521">“I’ve never felt this much behind as a programmer”</a>. Google engineers <a href="https://xcancel.com/rakyll/status/2007239758158975130">reported recreating months of work in hours</a>. It’s difficult to talk about this shift without sounding like an AI influencer-grifter, but the reaction was near-universal: this is different.</p>
<p>If your day job involves persuading voters, building coalitions, and running campaigns, it’s tempting to shrug. <em>Cool. The engineers have new toys.</em></p>
<p>That shrug is going to age poorly.</p>
<p>Because the important development isn’t “AI can code.” The important development is: AI can use code to do work. And most campaign work happens through software. The question isn’t “is this a coding task?” The question is: <strong>can this be done on a computer?</strong></p>
<p>If yes, you’re looking at tools that can handle the mechanical work that eats up campaign staff energy: searching, summarizing, transforming, moving information across systems. Not the judgment calls. Not the relationship-building. Not the strategy. But the tedious, repetitive, soul-crushing work that keeps smart people from doing what they’re actually good at. These tools can do that work at scale, in the messy reality of modern campaign operations.</p>
<p>To be clear: this isn’t about AI-generated content. The political AI conversation has been dominated by slop: cheaply generated images, robotic fundraising emails, uncanny video ads. That’s a real problem that’s eroding social trust, but it’s not what I’m talking about. Agentic AI isn’t about producing more stuff. It’s about operating the systems that produce, organize, and move information. The difference matters.</p>
<p>This isn’t a developer story. It’s a campaign knowledge story.</p>
<section id="the-misleading-word-in-coding-agent" class="level3">
<h3 class="anchored" data-anchor-id="the-misleading-word-in-coding-agent">The Misleading Word in “Coding Agent”</h3>
<p>When most people hear “coding,” they picture a specialist skill for a specialist domain. A software engineer building software. Most campaigns don’t have a software engineer.</p>
<p>But code is not sacred. Code is just instructions, it’s a language we use to tell computers to do things. If an AI can reliably write and execute those instructions, then the boundary of what it can do isn’t “software engineering.”</p>
<p>It’s your laptop.</p>
<ul>
<li>Spreadsheets are code, with a friendlier UI.</li>
<li>CRMs are code, behind an admin panel.</li>
<li>Compliance tooling is code, wrapped in forms and rules.</li>
<li>Digital ad platforms are code, with budgets and knobs.</li>
<li>Analytics pipelines are code, and they influence every decision you make.</li>
</ul>
<p>Successful campaigns don’t run on vibes. They run on systems: databases, tools, dashboards, inboxes, calendars, shared drives, web UIs held together by rituals and institutional memory.</p>
<p>A tool that can <em>operate those systems</em>, not just chat about them, isn’t a better chatbot. It’s a new kind of capacity.</p>
</section>
<section id="campaigns-are-mostly-knowledge-workers" class="level3">
<h3 class="anchored" data-anchor-id="campaigns-are-mostly-knowledge-workers">Campaigns Are Mostly Knowledge Workers</h3>
<p>Here’s what political people know but rarely say out loud: campaigns are giant, deadline-driven startups of knowledge work.</p>
<p>Not “knowledge work” in the TED Talk sense. Knowledge work in the gritty sense:</p>
<ul>
<li>reading and extracting</li>
<li>comparing and reconciling</li>
<li>drafting and reformatting</li>
<li>triaging and escalating</li>
<li>cleaning and validating</li>
<li>coordinating and scheduling</li>
<li>assembling packets, briefs, memos, scripts, cutlists, talking points, call time prep</li>
<li>turning raw inputs into something a decision-maker can act on</li>
</ul>
<p>A frightening amount of campaign labor is the same pattern repeated: take a messy pile of inputs, turn it into something legible, then turn that into action.</p>
<p>That is exactly the pattern these agentic tools are getting good at. And campaigns have endless <a href="https://en.wikipedia.org/wiki/The_American_Voter">messy piles of inputs</a>.</p>
</section>
<section id="weve-seen-this-movie-before" class="level3">
<h3 class="anchored" data-anchor-id="weve-seen-this-movie-before">We’ve Seen This Movie Before</h3>
<p>In 2020, while leading the Biden campaign’s engineering team, <a href="https://danveloper.medium.com/artificial-intelligence-on-the-biden-campaign-e704a656d956">we built early versions of what these tools now do out of the box.</a> We had automatic speech-to-text with real-time speaker diarization so our research team could dig into debate transcripts the moment they happened. We built semantic search across web, video, and podcast media so digital staff could find the exact clip they needed. We pushed browser automation to move data between the clunky legacy systems that every campaign inherits. <a href="https://en.wikipedia.org/wiki/AI_effect#Definition">Nobody thinks of this stuff as “AI” today</a>, but our piles of TensorFlow weirdware felt cutting-edge back then.</p>
<p>The capability existed in pieces. Integration was the hard part.</p>
<p>Getting each component working took real engineering. Wiring them into reliable, repeatable workflows took even more. And adoption was uneven because access was uneven. Teams with technical know-how used these tools constantly; teams without barely touched them.</p>
<p>Then the campaign ended, and all of it evaporated.</p>
<p>That’s the campaign technology pattern I’ve watched repeat for over a decade: brilliant, bespoke solutions built under pressure, then abandoned when the cycle ends. The institutional knowledge walks out the door. The next cycle starts from scratch.</p>
<p>What’s different now is generality. These agents don’t just generate code for engineers to review. They <em>use</em> code to accomplish tasks like navigating browsers, manipulating files, moving between systems. The integration problem doesn’t disappear, but the barrier drops dramatically.</p>
<p>The lesson I learned the hard way: capabilities arrive in fragments, and integration is the hard part. That’s still true. But the fragments are bigger now, and they snap together faster.</p>
</section>
<section id="what-this-actually-looks-like" class="level3">
<h3 class="anchored" data-anchor-id="what-this-actually-looks-like">What This Actually Looks Like</h3>
<p>Let’s be concrete about where this lands in campaign work.</p>
<p><strong>Comms and rapid response</strong> is where the ROI hits first. A lot of rapid response isn’t writing, it’s everything around writing. Pull the relevant clips from a debate transcript. Find the prior quote from six years ago, plus the local angle. Summarize what’s actually going viral, not what you wish was going viral. Draft three variants: statement, social copy, surrogate bullets. Generate a briefing doc with links and provenance so the comms director trusts it. Cut a “creator-ready” packet: 30-second version, caption variants, context notes.</p>
<p>If you’ve ever watched a comms team lose an hour hunting for the same receipts they hunted for last week, you know how these gains compound.</p>
<p><strong>Finance and fundraising</strong> is where the leverage is hiding in plain sight. The digital program alone generates endless grunt work: writing seventeen variants of the same end-of-quarter email to test subject lines, building SMS flows that branch based on giving history, reconciling ActBlue exports against your CRM at 11pm, figuring out why your recurring donor retention rate cratered last month. Then there’s the stewardship nobody has time for: the thank-you sequences that should feel personal but don’t, the post-event follow-ups that slip through the cracks, the lapsed-donor reactivation campaigns you keep meaning to build. An agent that can draft copy variants, analyze which segments are actually responding to which asks, and flag the recurring donors who just fell out of your latest filing isn’t replacing your finance team, it’s giving them back the hours they’re currently spending in spreadsheet hell.</p>
<p>Call time still matters, of course. The research-to-call-sheet pipeline is a natural fit: pull giving history, find recent board memberships, draft personalized ask language, log the notes afterward. But the real volume is in the grassroots program, and that’s where the gains compound. If your Digital Fundraising Director can test twice as many donation page variants, or your Finance Associate can actually build that monthly donor upgrade sequence instead of just talking about it, you’re not just saving time, you’re raising more money. In a cycle where small-dollar fundraising will matter more than ever, that’s the game.</p>
<p><strong>Listening at scale</strong> is the one that might matter most. Field teams drown in qualitative data: canvass notes, house party feedback, volunteer debriefs, open-ended survey responses, social comments from community groups. The tragedy is that we collect a ton of signal and then fail to convert it into shared understanding.</p>
<p>Agents are increasingly good at:</p>
<ul>
<li>clustering themes without flattening everything into mush</li>
<li>surfacing what changed since last week</li>
<li>producing reviewable summaries with links back to the raw text</li>
</ul>
<p>This is what “listening at scale” should mean: not replacing organizers, but amplifying their ability to hear patterns early and translate what they hear into narratives that feel true in people’s lives.</p>
<p><strong>Research</strong> is half insight and half mechanics. The mechanics are brutal: monitor media across dozens of geographies and channels, <a href="https://matthodges.com/posts/2025-12-19-ai-agent-fec/">track filings</a> and records, keep a living dossier updated as new facts drop. A good agent doesn’t need to be a genius researcher. It needs to be a relentless one.</p>
</section>
<section id="the-two-tier-risk" class="level3">
<h3 class="anchored" data-anchor-id="the-two-tier-risk">The Two-Tier Risk</h3>
<p>Here’s what worries me.</p>
<p>Right now, there’s a meaningful barrier to entry for these tools. You need to be comfortable with a terminal window and a handful of basic commands. This isn’t hard, but it’s intimidating enough to create a split: the people who learn the workflows compound their productivity quickly, and everyone else hears about it in annoying LinkedIn posts, wondering why they feel behind.</p>
<p>For Democratic political staff, this is an acute risk. Our coalition depends on down-ballot races, state parties, and grassroots organizations that have never had the technical resources of presidential campaigns. If agentic AI remains “for engineers only,” the productivity gap between well-resourced and under-resourced campaigns will widen dramatically, and it’ll widen fastest in exactly the races where we can least afford it.</p>
<p>The good news is that accessibility is a UX problem, and UX problems get solved. We’re already seeing agent capabilities land in <a href="https://claude.com/claude-in-excel">spreadsheets</a>, <a href="https://code.claude.com/docs/en/chrome">browsers</a>, and <a href="https://help.figma.com/hc/en-us/articles/32132100833559-Guide-to-the-Figma-MCP-server">familiar interfaces</a>. But in the window before it becomes seamless, early adopters will pull away.</p>
<p>If every well-funded campaign builds bespoke mini-machines while everyone else waits for permission, we don’t get a movement capability. We get a handful of fragile miracles and a much longer tail of campaigns <a href="https://campaignsandelections.com/voices/democrats-still-arent-ready-for-2026/">stuck in the last cycle</a>. The path forward runs through the organizations that don’t shut down in November: party committees, major vendors, funders who take infrastructure seriously, and the handful of political tech orgs building for the long haul.</p>
</section>
<section id="the-shadow-ai-trap" class="level3">
<h3 class="anchored" data-anchor-id="the-shadow-ai-trap">The Shadow AI Trap</h3>
<p>There’s an obvious failure mode here: everyone quietly experimenting, pasting sensitive data into whatever tool is easiest, building brittle automations with no discernible schema, accidentally recreating the worst parts of shadow IT but with higher stakes.</p>
<p>Agentic tools reach across systems: files, browsers, inboxes, CRMs. That means governance can’t be an afterthought or a scolding memo. It has to be defaults: what data is allowed where, what gets automated, what requires approval, what runs in a sandbox.</p>
<p>The goal is not to slow people down. The goal is to prevent the inevitable <a href="https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/">“we moved fast and broke trust”</a> moment that causes a backlash and sets adoption back a cycle. Campaigns can’t hold institutional memory about what went wrong because they dissolve. The enduring organizations have to own governance, because they’re the ones who’ll still be around to learn from the mistakes.</p>
</section>
<section id="what-to-do-now" class="level3">
<h3 class="anchored" data-anchor-id="what-to-do-now">What to Do Now</h3>
<p>Individual experimentation is fine, but it’s not sufficient. If the last decade of Democratic technology has taught us anything, it’s that innovation without infrastructure just produces a graveyard of one-off solutions. The work that matters now is building shared, composable capacity that outlasts any single campaign.</p>
<p>That means the institutional actors have to lead.</p>
<p><strong>Party committees</strong> need to treat agentic AI as core infrastructure, not innovation theater. That means dedicated technical staff evaluating tools, building reusable workflows, and making them available to down-ballot campaigns that will never have their own AI strategy. National committees should be standing up these shared services, not just issuing guidance memos.</p>
<p><strong>Vendors and tool-builders</strong> should be building agent capabilities into the platforms campaigns already use. The goal isn’t a new AI product to sell; it’s making the existing stack smarter. If you build campaign tech, the question is: what mechanical work can agents automate so your users can focus on judgment and relationships?</p>
<p><strong>Major funders</strong> need to back infrastructure, not just applications, and definitely not just ad mills. That means supporting the unsexy work: integration layers, shared tooling, governance frameworks, training programs. It means backing organizations that will maintain and improve these capabilities across cycles, not just campaigns that will use them once and disappear.</p>
<p><strong>Tech-forward political organizations</strong> that persist between elections should be the R&amp;D layer for the movement. Test workflows, document what works, publish patterns that others can adopt. Build the institutional memory that campaigns can’t.</p>
<p>For those experimenting now, two principles:</p>
<p><strong>Bias toward real workflows.</strong> Not “build the future of AI.” Pick the task you do every week that makes you groan. Start there. High-frequency tasks where saved time compounds.</p>
<p><strong>Design for review, not autonomy.</strong> Have the agent produce outputs a human can verify: citations, diffs, structured summaries, highlighted uncertainties. The goal is human-in-the-loop, not human-out-of-the-way. Judgment stays with people, because political work is still inherently about people.</p>
<p>None of this requires believing in AGI fantasies. It requires noticing what’s in front of us: campaign staff are buried in mechanical knowledge work. Scarce energy is spent on tedious, repetitive tasks that crowd out strategic thinking. And the tools that can handle a lot that work are finally here and improving rapidly. The organizations that treat this as a real infrastructure priority will free their people to do what humans are actually good at: building relationships, making judgment calls, being an authentic voice, and persuading voters.</p>
<p>Not louder machines. Better listeners. Faster integrators. Teams with room to think.</p>
<p>Agentic AI will transform knowledge work this year. The only question is whether we build the shared capacity to make it Democrats’ advantage, or explain after the fact why we let another cycle of innovation pass.</p>


</section>

 ]]></description>
  <guid>https://matthodges.com/posts/2026-01-07-ai-agents-campaigns/</guid>
  <pubDate>Wed, 07 Jan 2026 06:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2026-01-07-ai-agents-campaigns/ai-agents-campaigns-machines.jpg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>My New Agentic Campaign Finance Sidekick</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2025-12-19-ai-agent-fec/</link>
  <description><![CDATA[ 





<p>Claude <a href="https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview">Agent Skills</a> are pretty neat. They remind me of OpenAI’s <a href="https://platform.openai.com/docs/guides/function-calling">function calling</a>. Both are a way to tell the model to run some external process, often with the goal of seeding the context window with deterministic data from outside the LLM system. Function calling does this with a pile of JSON. Agent Skills do it with a pile of Markdown.</p>
<div class="callout callout-style-default callout-tip callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
<span class="screen-reader-only">Tip</span>Now Works With Codex!
</div>
</div>
<div class="callout-body-container callout-body">
<p>OpenAI’s <a href="https://developers.openai.com/codex/cli/">Codex CLI</a> now supports the open <a href="https://agentskills.io">Agent Skills standard</a> so this now works with Claude Code or Codex!</p>
</div>
</div>
<p><a href="https://matthodges.com/posts/2025-08-02-language-model-embeddings-campaign-donors/">I like to do weird things with campaign finance data</a>, and I thought it’d be nice to have an agentic sidekick to crawl around FEC files with me. There’s a ton of prior art here, so it’s a great use case for a Skill. The most relevant prior art is Derek Willis’s <a href="https://github.com/dwillis/llm-fecfile">llm-fecfile</a>, which wraps Evan Sonderegger’s venerable <a href="https://github.com/esonderegger/fecfile">fecfile</a>, and is a plugin for Simon Willison’s <a href="https://llm.datasette.io/en/stable/">LLM CLI tool</a>.</p>
<p>So I set out to make an Agent Skill. Except I didn’t have to do all that much.</p>
<p>I popped open Claude Code (Opus 4.5) and asked for a plan:</p>
<blockquote class="blockquote">
<p>I have cloned the llm plugin llm-fecfile to <span class="citation" data-cites="llm-fecfile">@llm-fecfile</span>/ . I would like to convert it to an Agent Skill. Let’s break it into tasks.</p>
</blockquote>
<pre><code>⏺ Here's the task breakdown for converting llm-fecfile to an Agent Skill:

  Tasks

  | # | Task                                                                       |
  |---|----------------------------------------------------------------------------|
  | 1 | Set up `.claude/skills/fec-filing/` with the required layout               |
  | 2 | Write SKILL.md file with YAML frontmatter and main instructions            |
  | 3 | Create FORMS.md with specific guidance for F1, F2, F3, F99 filings         |
  | 4 | Create SCHEDULES.md with field mappings for Schedules A, B, C, D, E        |
  | 5 | Create script to fetch and display FEC filings using the `fecfile` library |
  | 6 | Verify Claude can analyze an FEC filing using the new skill                |


  Key Conversion Notes

  - Plugin vs Skill: The LLM plugin uses a fragment loader (-f fec:ID). The skill will
  instead instruct Claude to use a Python script via Bash to fetch filings.
  - Documentation: The rich analysis instructions in llm_fecfile.py will become markdown
  reference docs that Claude reads.
  - Dependencies: The skill will require fecfile to be installed.

  Should I proceed with this plan, or would you like to adjust any tasks?</code></pre>
<blockquote class="blockquote">
<p>Yes, let’s go.</p>
</blockquote>
<p>From there, it began porting all of Derek’s hard work into the necessary Markdown and script structure. Minutes later, I had <a href="https://github.com/hodgesmr/agent-fecfile">agent-fecfile</a> (formerly called <code>claude-fecfile</code>) and you can have it too:</p>
<div class="callout callout-style-default callout-warning callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Warning
</div>
</div>
<div class="callout-body-container callout-body">
<p>Installing someone else’s Agent Skill means you’re letting the agent run someone else’s <strong>prompts</strong> and <strong>code</strong>. I trust me. Do you trust me?</p>
</div>
</div>
<p>If you run this, the skill is now available globally in all your Claude Code sessions:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb2-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">git</span> clone git@github.com:hodgesmr/agent-fecfile.git /tmp/agent-fecfile</span>
<span id="cb2-2"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">cp</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-R</span> /tmp/agent-fecfile/skills/fecfile ~/.claude/skills/</span></code></pre></div></div>
<p>Or if you run this, the skill is now available globally in all your Codex sessions:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb3-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">git</span> clone git@github.com:hodgesmr/agent-fecfile.git /tmp/agent-fecfile</span>
<span id="cb3-2"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">cp</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-R</span> /tmp/agent-fecfile/skills/fecfile ~/.codex/skills/</span></code></pre></div></div>
<div class="callout callout-style-default callout-note callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Note
</div>
</div>
<div class="callout-body-container callout-body">
<p>New install instructions are documented in the <a href="https://github.com/hodgesmr/agent-fecfile">agent-fecfile repository</a>.</p>
</div>
</div>
<p>Make sure you also have <a href="https://docs.astral.sh/uv/">uv</a> installed.</p>
<p>And now you can get the agent to do things like this:</p>
<blockquote class="blockquote">
<p>Show me a table of the contribution counts and totals, by state, in fec filing 1896830</p>
</blockquote>
<pre><code>⏺ FEC Filing 1896830 - Contributions by State

  | State | Count |      Total | % of Total |
  |-------|-------|------------|------------|
  | UT    |    32 | $35,042.25 |      87.0% |
  | VA    |     2 |  $2,400.00 |       6.0% |
  | GA    |     1 |    $750.00 |       1.9% |
  | MI    |     1 |    $750.00 |       1.9% |
  | MD    |     2 |    $750.00 |       1.9% |
  | FL    |     1 |    $600.00 |       1.5% |
  | TOTAL |    39 | $40,292.25 |       100% |

  Utah dominates with 87% of the total contributions. The committee received from
  6 states total, with only 7 out-of-state contributions.</code></pre>
<p>And things like this:</p>
<blockquote class="blockquote">
<p>What are the largest expenditures in filing 1896830?</p>
</blockquote>
<pre><code>⏺ FEC Filing 1896830 - Largest Expenditures

  | #   | Recipient                  |    Amount | Date       | Purpose          |
  |-----|----------------------------|-----------|------------|------------------|
  | 1   | Trump National Doral Hotel | $1,013.61 | 2025-05-19 | RNC training     |
  | 2   | Uber.com                   |    $19.77 | 2025-05-06 | Travel - Cabfare |
  |     | TOTAL (itemized)           | $1,033.38 |            |                  |

  Note

  Only 2 expenditures are itemized on Schedule B. The filing shows $21,283.49 in total
  disbursements, meaning most spending (~$20,250) was below the $200 itemization
  threshold and appears only in summary totals.

  The largest single itemized expense was for RNC training at Trump National Doral Hotel
  in May 2025.</code></pre>
<div class="callout callout-style-default callout-warning callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Warning
</div>
</div>
<div class="callout-body-container callout-body">
<p>FEC filings should be considered <a href="https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/">untrusted content</a>. A malicious campaign sneaking prompt injections into the memo text field of their F99 is probably unlikely, but not impossible.</p>
</div>
</div>
<p>Agents!</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2025-12-19-ai-agent-fec/</guid>
  <pubDate>Fri, 19 Dec 2025 06:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2025-12-19-ai-agent-fec/claude-fecfile-agent.png" medium="image" type="image/png" height="88" width="144"/>
</item>
<item>
  <title>Claude in a Game Theory Tournament</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2025-12-14-claude-axelrod-prisoners-dilemma/</link>
  <description><![CDATA[ 





<p>I’ve been playing around with Claude Code, and I wanted to see what happens when you give it something genuinely open-ended. Not “implement this feature” or “fix this bug” but something that would typically require actual creativity from a human. Something where the agent needs to look at a landscape of existing solutions, find the gaps, and make something new.</p>
<p>The <a href="https://en.wikipedia.org/wiki/Prisoner%27s_dilemma#The_iterated_prisoner's_dilemma">Iterated Prisoner’s Dilemma</a> felt perfect for this. It’s a well-studied problem with decades of research and it allows you to objectively measure how good your strategy is by running a tournament. So I gave Claude Code (Sonnet 4.5) one prompt to look at the <a href="https://github.com/Axelrod-Python/Axelrod">Axelrod library’s</a> 200+ IPD strategies, come up with something novel that could actually compete, and build it.</p>
<section id="time-for-some-game-theory" class="level3">
<h3 class="anchored" data-anchor-id="time-for-some-game-theory">Time For Some Game Theory</h3>
<p>The traditional <a href="https://en.wikipedia.org/wiki/Prisoner%27s_dilemma">Prisoner’s Dilemma</a> is one of game theory’s most studied problems. Two players simultaneously choose to either <strong>Cooperate</strong> or <strong>Defect</strong>. The payoff matrix creates a tension between individual and collective benefit:</p>
<table class="caption-top table">
<colgroup>
<col style="width: 34%">
<col style="width: 33%">
<col style="width: 32%">
</colgroup>
<thead>
<tr class="header">
<th></th>
<th>You Cooperate</th>
<th>You Defect</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><strong>Opponent Cooperates</strong></td>
<td>R = 3 (Reward)</td>
<td>T = 5 (Temptation)</td>
</tr>
<tr class="even">
<td><strong>Opponent Defects</strong></td>
<td>S = 0 (Sucker’s payoff)</td>
<td>P = 1 (Punishment)</td>
</tr>
</tbody>
</table>
<p>The dilemma: mutual cooperation yields 3 points each (Reward), but you’re tempted to defect for 5 points while your opponent gets 0 (Sucker’s payoff). But if you both defect, you each only get 1 point.</p>
<p><strong>In a one-shot game, rational self-interest says defect.</strong> But what if you play repeatedly?</p>
<p>In 1980, political scientist <a href="https://en.wikipedia.org/wiki/Robert_Axelrod_(political_scientist)">Robert Axelrod</a> adjusted the idea, and organized a computational tournament where experts across various fields submitted strategies for the <strong>Iterated Prisoner’s Dilemma (IPD)</strong>. The traditional Prisoner’s Dilemma was a single, one-shot game: two players choose cooperate or defect once, receive payoffs, and the interaction ends. Axelrod’s Iterated Prisoner’s Dilemma instead pits many strategies against each other in a round-robin tournament, where each pair plays the same opponent repeatedly over many rounds and total scores are accumulated. Because moves can depend on the history of play, strategies can reward cooperation, punish defection, and recover from mistakes.</p>
<p>The winner was <a href="https://en.wikipedia.org/wiki/Anatol_Rapoport">Anatol Rapoport</a> and his strategy <strong><a href="https://en.wikipedia.org/wiki/Tit_for_tat">Tit For Tat</a></strong>: start by cooperating, then mirror your opponent’s last move. The simplicity surprised everyone. More complex strategies lost to this extremely basic algorithm.</p>
<p>Axelrod’s 1984 book <em><a href="https://en.wikipedia.org/wiki/The_Evolution_of_Cooperation">The Evolution of Cooperation</a></em> analyzed why:</p>
<ul>
<li><strong>Nice strategies</strong> (never defect first) tended to win in the long run</li>
<li><strong>Forgiving strategies</strong> (don’t hold grudges forever) did well</li>
<li><strong>Clear strategies</strong> (opponents can understand your pattern) encouraged cooperation</li>
<li><strong>Retaliatory strategies</strong> (punish defection) prevented exploitation</li>
</ul>
<p>The key discovery was that while the traditional Prisoner’s Dilemma elevated defection as the logical play, the Iterated Prisoner’s Dilemma tended to reward cooperation. These findings influenced fields from evolutionary biology to international relations. If cooperation can emerge from pure self-interest in simple games, perhaps it can explain cooperation in nature and human societies. <a href="https://www.youtube.com/watch?v=mScpHTIi-kM">Veritasium did a great video summarizing the book</a>, but you should read the book!</p>
<p>The <a href="https://github.com/Axelrod-Python/Axelrod">Axelrod library</a> continues this research. It’s a comprehensive Python framework containing:</p>
<ul>
<li>200+ strategy implementations (classic and modern)</li>
<li>Tournament infrastructure</li>
<li>Statistical analysis and visualization tools</li>
<li><a href="https://en.wikipedia.org/wiki/Moran_process">Moran process</a> simulation for evolutionary dynamics</li>
<li>Support for noise, probabilistic endings, and spatial tournaments</li>
</ul>
<p>Strategies range from simple (<a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.titfortat.TitForTat">Tit For Tat</a>, <a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.grudger.Grudger">Grudger</a>) to complex (neural networks, finite state machines, zero-determinant strategies). It’s maintained by researchers and serves as a testbed for game theory experiments.</p>
</section>
<section id="claude-in-the-tournament" class="level3">
<h3 class="anchored" data-anchor-id="claude-in-the-tournament">Claude In The Tournament</h3>
<p>So I cloned the Axelrod library and gave Claude Code this prompt:</p>
<blockquote class="blockquote">
<p><em>This is the Axelrod python library that implements the tournament-style iterative prisoners’ dilemma popularized by Robert Axelrod in his seminal work The Evolution of Cooperation. We’re going to try to create a novel and competitive strategy, one that stands on its own and can reliably compete with the bests known strategies, like Tit-For-Tat. The repository ships with over 200 strategies already for the tournament. I would like you to think hard about what a novel new strategy could be. This is going to require creativity and thinking outside the box. It’ll likely take some iteration too. Let’s begin. Explore the repository for existing strategies, understand how the tournament system works, and then, once you’ve created a novel and creative idea, implement the new strategy. Once implemented, run it in a tournament to see how it performs against all others.</em></p>
</blockquote>
<p>The interesting part here is what I didn’t specify. I didn’t tell it to use any particular approach. I just said: be novel, be competitive, figure it out. This is different from most coding tasks, and different from how I typically advise users getting started with AI tools. It requires the agent to do research, synthesize information, identify opportunities, and validate its own work.</p>
<p>The agent started by exploring. It read through the strategy implementations systematically, looking through dozens of Python files, each containing multiple strategies. It looked at how tournaments work, how strategies are classified, what the existing approaches are.</p>
<p>Then it started cataloguing: memory-based strategies that track opponent history, stochastic strategies that use randomness, zero-determinant strategies that unilaterally enforce a linear relationship between players’ expected payoffs, evolved strategies optimized through genetic algorithms.</p>
<blockquote class="blockquote">
<p><code>I'll help you create a novel and competitive strategy for the Iterated Prisoner's Dilemma! This is an exciting challenge. Let me start by exploring the repository to understand the existing strategies and tournament mechanics.</code></p>
</blockquote>
<p>After looking through all those approaches, the agent landed on a pretty specific gap: nobody was doing Bayesian opponent modeling in the straightforward way you’d expect. Lots of strategies adapt. Some keep simple stats like “how often does this opponent cooperate?” Some try to bucket opponents into types. But I didn’t see any that keep an explicit range of beliefs about how cooperative the other player is, and then use how confident (or uncertain) they are in that read to decide when to forgive or punish.</p>
<blockquote class="blockquote">
<p><code>Based on my analysis, I'm going to create a strategy that fills a critical gap: Bayesian opponent modeling with uncertainty-aware decision making.</code></p>
</blockquote>
<p>That became the core insight: use uncertainty as a guardrail. When you don’t know what you’re dealing with yet, don’t hand out forgiveness cheaply. But once you’ve got enough evidence that an opponent is usually cooperative, you can afford to treat an occasional defection as noise, and forgive it, while still clamping down hard on players who keep defecting.</p>
</section>
<section id="claudes-strategy" class="level3">
<h3 class="anchored" data-anchor-id="claudes-strategy">Claude’s Strategy</h3>
<p>The strategy Claude Code designed has a simple core idea: model your opponent’s cooperation probability using a <a href="https://en.wikipedia.org/wiki/Beta_distribution">Beta distribution</a>, and let your uncertainty affect how forgiving you are.</p>
<p>The Beta distribution is perfect for this. You’re uncertain about the opponent’s true cooperation rate, so you maintain a distribution of possible values.</p>
<ul>
<li><strong>α (alpha)</strong>: observed cooperations + prior</li>
<li><strong>β (beta)</strong>: observed defections + prior</li>
<li><strong>Mean cooperation probability</strong>: <img src="https://latex.codecogs.com/png.latex?%5Cmu%20=%20%5Cfrac%7B%5Calpha%7D%7B%5Calpha%20+%20%5Cbeta%7D"></li>
<li><strong>Uncertainty (standard deviation)</strong>: <img src="https://latex.codecogs.com/png.latex?%5Csigma%20=%20%5Csqrt%7B%5Cfrac%7B%5Calpha%20%5Cbeta%7D%7B(%5Calpha%20+%20%5Cbeta)%5E2%20(%5Calpha%20+%20%5Cbeta%20+%201)%7D%7D"></li>
</ul>
<p>Start with <code>Beta(1,1)</code>, which is completely flat from 0 to 1. You have no idea if they’ll cooperate or not.</p>
<p>Every time they cooperate, you increment α. Every time they defect, you increment β. The distribution gradually shifts and narrows as you observe more data. After 100 rounds with 70 cooperations, you’ve got <code>Beta(71, 31)</code>, a distribution peaked around 70% cooperation with relatively low uncertainty. After just 5 rounds with 3 cooperations, you’ve got <code>Beta(4, 3)</code>, which is still pretty uncertain.</p>
<p>Early on, uncertainty is high, which raises the forgiveness threshold, so the strategy is conservative about forgiving. As evidence accumulates, uncertainty shrinks and the threshold falls back toward the base threshold, making forgiveness possible once the estimated cooperation rate is convincingly high.</p>
<p>The decision rule is straightforward: if the opponent just cooperated, cooperate back. If they just defected, check their cooperation rate. If it’s above your current threshold, forgive them and cooperate. If it’s below, defect.</p>
<p>This creates adaptive behavior. Against cooperative opponents, you quickly learn they’re trustworthy and maintain cooperation. Against occasionally exploitative opponents, confidence gives them a chance to reform. Against consistently hostile opponents, you become confident in their hostility and stop forgiving.</p>
<div id="9bb70114" class="cell" data-execution_count="20">
<div class="cell-output cell-output-display">
<div>
<figure class="figure">
<p><img src="https://matthodges.com/posts/2025-12-14-claude-axelrod-prisoners-dilemma/index_files/figure-html/cell-3-output-1.svg" class="img-fluid figure-img"></p>
</figure>
</div>
</div>
</div>
<p>Claude Code implemented this cleanly, largely thanks to the excellent documentation provided by the library. It wrote the strategy with proper parameter handling, reset logic, and classification metadata. It registered the strategy in the library’s index. It wrote comprehensive tests.</p>
<p>The test suite is thorough. Basic behavior tests included expectations like “cooperate on first move” and “maintain cooperation with always-cooperate opponent.” Edge case tests against <a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.defector.Defector">Defector</a> and <a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.alternator.Alternator">Alternator</a>. Strategy mechanics tests to verify the Bayesian updating works correctly. A clone test to ensure parameters are preserved.</p>
</section>
<section id="iterating-and-improving" class="level3">
<h3 class="anchored" data-anchor-id="iterating-and-improving">Iterating and Improving</h3>
<p>After it ran an initial tournament of BayesianForgiver against classic strategies, it decided to tune the strategy’s default parameters:</p>
<blockquote class="blockquote">
<p><code>I'll create a tournament script that imports and runs the strategy against others. Let me check if there are existing examples of running tournaments.</code></p>
</blockquote>
<p>…</p>
<blockquote class="blockquote">
<p><code>Great! The tournament ran successfully! BayesianForgiver ranked 9th out of 15 in the classic tournament. This is decent but there's room for improvement. The results show that the strategy is working but could be tuned better.</code></p>
</blockquote>
<p>…</p>
<blockquote class="blockquote">
<p><code>The strategy is novel and functional, but let me try tuning the parameters to improve performance</code></p>
</blockquote>
<p>…</p>
<blockquote class="blockquote">
<p><code>Excellent! The parameter tuning found significantly better parameters! Let me update the strategy with the optimized parameters</code></p>
</blockquote>
<p>This landed it at <strong>6/15</strong> in a smaller classic tournament against core strategies:</p>
<ul>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.titfortat.TitForTat"><strong>Tit For Tat</strong></a> - The original champion: cooperate first, then mirror opponent’s last move</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.cooperator.Cooperator"><strong>Cooperator</strong></a> - Always cooperates unconditionally</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.defector.Defector"><strong>Defector</strong></a> - Always defects unconditionally</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.grudger.Grudger"><strong>Grudger</strong></a> - Cooperates until the opponent defects once, then defects forever</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.titfortat.TitFor2Tats"><strong>Tit For 2 Tats</strong></a> - Only defects after two consecutive defections by opponent</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.memoryone.WinStayLoseShift"><strong>Win-Stay Lose-Shift</strong></a> - If the last round produced a good payoff, repeat; if bad, switch</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.memoryone.GTFT"><strong>GTFT</strong></a> - Generous Tit For Tat: occasionally cooperates even after opponent defects</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.rand.Random"><strong>Random</strong></a> - Randomly chooses between cooperation and defection with equal probability</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.titfortat.SuspiciousTitForTat"><strong>Suspicious Tit For Tat</strong></a> - Like Tit For Tat but starts by defecting instead of cooperating</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.titfortat.HardTitForTat"><strong>Hard Tit For Tat</strong></a> - Defects after any defection and only cooperates after three consecutive cooperations</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.adaptive.Adaptive"><strong>Adaptive</strong></a> - Learns from history: plays the response that would have maximized its own score</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.apavlov.APavlov2011"><strong>Adaptive Pavlov 2011</strong></a> - Classifies opponents into types and adapts strategy based on opponent classification</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.forgiver.ForgivingTitForTat"><strong>Forgiving Tit For Tat</strong></a> - Like Tit For Tat but forgives defections with 10% probability</li>
<li><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.gobymajority.GoByMajority"><strong>Go By Majority</strong></a> - Cooperates if opponent has cooperated more than defected in history</li>
</ul>
<p>In a tournament where each pair of strategies plays 20 separate matches of 200 rounds:</p>
<table class="caption-top table">
<thead>
<tr class="header">
<th style="text-align: right;">Rank</th>
<th>Strategy</th>
<th style="text-align: right;">Avg Score</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td style="text-align: right;">1</td>
<td>Grudger</td>
<td style="text-align: right;">7956.40</td>
</tr>
<tr class="even">
<td style="text-align: right;">2</td>
<td>Win-Stay Lose-Shift</td>
<td style="text-align: right;">7914.90</td>
</tr>
<tr class="odd">
<td style="text-align: right;">3</td>
<td>Adaptive Pavlov 2011</td>
<td style="text-align: right;">7880.00</td>
</tr>
<tr class="even">
<td style="text-align: right;">4</td>
<td>Tit For 2 Tats</td>
<td style="text-align: right;">7763.20</td>
</tr>
<tr class="odd">
<td style="text-align: right;">5</td>
<td>Forgiving Tit For Tat</td>
<td style="text-align: right;">7741.95</td>
</tr>
<tr class="even">
<td style="text-align: right;"><strong>6</strong></td>
<td><strong>Bayesian Forgiver</strong></td>
<td style="text-align: right;"><strong>7735.55</strong></td>
</tr>
<tr class="odd">
<td style="text-align: right;">7</td>
<td>Tit For Tat</td>
<td style="text-align: right;">7733.95</td>
</tr>
<tr class="even">
<td style="text-align: right;">8</td>
<td>Hard Tit For Tat</td>
<td style="text-align: right;">7554.35</td>
</tr>
<tr class="odd">
<td style="text-align: right;">9</td>
<td>GTFT</td>
<td style="text-align: right;">7538.10</td>
</tr>
<tr class="even">
<td style="text-align: right;">10</td>
<td>Go By Majority</td>
<td style="text-align: right;">7354.30</td>
</tr>
<tr class="odd">
<td style="text-align: right;">11</td>
<td>Cooperator</td>
<td style="text-align: right;">6912.60</td>
</tr>
<tr class="even">
<td style="text-align: right;">12</td>
<td>Adaptive</td>
<td style="text-align: right;">6703.55</td>
</tr>
<tr class="odd">
<td style="text-align: right;">13</td>
<td>Suspicious Tit For Tat</td>
<td style="text-align: right;">6450.65</td>
</tr>
<tr class="even">
<td style="text-align: right;">14</td>
<td>Random</td>
<td style="text-align: right;">5713.20</td>
</tr>
<tr class="odd">
<td style="text-align: right;">15</td>
<td>Defector</td>
<td style="text-align: right;">4725.20</td>
</tr>
</tbody>
</table>
</section>
<section id="the-tournament" class="level3">
<h3 class="anchored" data-anchor-id="the-tournament">The Tournament</h3>
<p>I then asked Claude Code to run a comprehensive tournament following the examples from the <a href="https://github.com/Axelrod-Python/tournament">tournament repository</a>.</p>
<p>BayesianForgiver ranked <strong>93rd</strong> out of 226 strategies. Top 41%.</p>
<p>Let’s be clear: this isn’t elite performance. The top positions are dominated by evolved strategies based on finite state machines, neural networks, hidden Markov models that were optimized via evolutionary algorithms specifically for IPD success. But here’s what matters: BayesianForgiver introduced a genuinely novel approach and proved it’s competitive with those hand-crafted strategies. It beat some famous names from the literature. And it validated the core insight about certainty-aware forgiveness.</p>
<p><a href="https://axelrod.readthedocs.io/en/stable/reference/strategy_index.html#axelrod.strategies.memoryone.WinStayLoseShift">Win-Stay, Lose-Shift</a> (WSLS), also called Pavlov, is one of the landmark strategies in Iterated Prisoner’s Dilemma research. A key modern reference point is <a href="https://pubmed.ncbi.nlm.nih.gov/8316296/">Nowak and Sigmund’s 1993 paper</a>, which highlighted WSLS/Pavlov and showed that it can outperform Tit For Tat in a range of settings. The rule is elegantly simple: if the last round produced a “good” payoff, repeat your previous move; if it produced a “bad” payoff, switch.</p>
<p>The strategy is famous for its ability to correct mistakes and re-establish cooperation under noise: a single accidental defection doesn’t necessarily lock WSLS into long retaliation cycles, and two WSLS players can often return to mutual cooperation quickly. It has been extensively studied in the theoretical and evolutionary game theory literature.</p>
<p>WSLS also has a well-known weakness in heterogeneous tournaments. Because it is a deterministic, memory-one rule that reacts only to the last outcome, it can be systematically exploited by certain opponents, such as those that defeat WSLS every other round by keeping it trapped in an alternating pattern. More generally, without modeling longer-term opponent behavior, WSLS can get pulled into unfavorable cycles against particular defection patterns.</p>
<p><strong>BayesianForgiver ranked above WSLS</strong> in the comprehensive tournament (rank 93 vs.&nbsp;rank 108). This was extremely validating.</p>
<p>BayesianForgiver doesn’t overreact to single defections; it builds a statistical model. As the game progresses, the Beta distribution captures the opponent’s cooperation pattern. The adaptive threshold prevents getting locked into bad cycles while maintaining cooperation with reasonable opponents.</p>
</section>
<section id="agentic-success" class="level3">
<h3 class="anchored" data-anchor-id="agentic-success">Agentic Success</h3>
<p>Let’s zoom out and look at what actually happened here.</p>
<p>Claude Code took an open-ended challenge and executed the full lifecycle: autonomous research through 200+ strategies, novel design creating a Bayesian opponent-modeling strategy, complete implementation with proper structure and tests based on the library’s documentation, self-initiated optimization, iterative improvement raising performance from rank, large-scale validation, and comprehensive documentation of everything.</p>
<p>This isn’t remarkable because “AI can code.” We know that. It’s remarkable because of what happened in between: the creative ideating, the autonomous decision to optimize, the ability to validate its own work objectively.</p>
<p>The Iterated Prisoner’s Dilemma turns out to be an ideal testbed for this. It’s <strong>well-defined</strong> with clear rules and objective scoring. It’s <strong>well-studied</strong> with hundreds of existing strategies to learn from. It’s competitive with tournament rankings providing <strong>objective evaluation</strong>. And it’s rich enough that despite all that existing work, <strong>gaps still exist</strong>.</p>
<p>This combination lets the agent verify its work objectively, learn from existing solutions, iterate based on data, and balance creativity with rigor. Those are exactly the capabilities that matter for real engineering work.</p>
<p>I think this is generalizable to other problem domains. Remember: agents are just <a href="https://simonwillison.net/2025/Sep/18/agents/">LLMs using tools in a loop</a> and <a href="https://fly.io/blog/everyone-write-an-agent/">you can definitely write your own</a>.</p>
<p>With a few key requirements, agents are likely to have more success in “creative” endeavors:</p>
<ol type="1">
<li><strong>Objective evaluation metric</strong> - The agent needs to know if its solution is good</li>
<li><strong>Existing corpus of solutions</strong> - Learn from prior art, identify gaps</li>
<li><strong>Ability to iterate</strong> - Test variations, optimize parameters</li>
<li><strong>Constrained search space</strong> - Not infinite possibilities, but creative freedom within boundaries</li>
</ol>
<p>When you have all four of these, agentic coding can potentially handle (more of) the full loop from problem to solution. The human provides problem framing, constraint setting, pointers to existing work, and final evaluation and review. The agent provides exploration, creative solution design, implementation, empirical validation, and iterative improvement.</p>
</section>
<section id="try-it-yourself" class="level3">
<h3 class="anchored" data-anchor-id="try-it-yourself">Try It Yourself</h3>
<p>You can install from my <a href="https://github.com/hodgesmr/Axelrod/tree/bayesian-forgiver-strategy">fork</a> to try it yourself:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb1-1"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">uv</span> pip install <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"git+https://github.com/hodgesmr/Axelrod@bayesian-forgiver-strategy"</span></span></code></pre></div></div>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb2-1"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> axelrod <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">as</span> axl</span>
<span id="cb2-2"></span>
<span id="cb2-3">players <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> [</span>
<span id="cb2-4">    axl.BayesianForgiver(),</span>
<span id="cb2-5">    axl.TitForTat(),</span>
<span id="cb2-6">    axl.Cooperator(),</span>
<span id="cb2-7">    axl.Defector(),</span>
<span id="cb2-8">    axl.Grudger(),</span>
<span id="cb2-9">    axl.TitFor2Tats(),</span>
<span id="cb2-10">    axl.WinStayLoseShift(),</span>
<span id="cb2-11">    axl.GTFT(),</span>
<span id="cb2-12">    axl.Random(),</span>
<span id="cb2-13">    axl.SuspiciousTitForTat(),</span>
<span id="cb2-14">    axl.HardTitForTat(),</span>
<span id="cb2-15">    axl.Adaptive(),</span>
<span id="cb2-16">    axl.APavlov2011(),</span>
<span id="cb2-17">    axl.ForgivingTitForTat(),</span>
<span id="cb2-18">    axl.GoByMajority(),</span>
<span id="cb2-19">]</span>
<span id="cb2-20"></span>
<span id="cb2-21">tournament <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> axl.Tournament(players<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>players, turns<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">200</span>, repetitions<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">20</span>)</span>
<span id="cb2-22">results <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> tournament.play(progress_bar<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>)</span>
<span id="cb2-23"></span>
<span id="cb2-24"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">for</span> i, name <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">enumerate</span>(results.ranked_names):</span>
<span id="cb2-25">    idx <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> results.players.index(name)</span>
<span id="cb2-26">    avg_score <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">sum</span>(results.scores[idx]) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">/</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">len</span>(results.scores[idx])</span>
<span id="cb2-27">    <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(<span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">f"</span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">{</span>name<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">}</span><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;"> : </span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">{</span>avg_score<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">}</span><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span>)</span></code></pre></div></div>
<p>Can you beat BayesianForgiver with your own strategy?</p>


</section>

 ]]></description>
  <guid>https://matthodges.com/posts/2025-12-14-claude-axelrod-prisoners-dilemma/</guid>
  <pubDate>Sun, 14 Dec 2025 06:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2025-12-14-claude-axelrod-prisoners-dilemma/claude-game-theory.jpeg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>Dad</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2025-11-02-dad/</link>
  <description><![CDATA[ 





<p>On <strong>October 22, 2025</strong> we got the call from Kelsey’s OBGYN that we were going to the hospital that night. The due date wasn’t until November 17, but it was time to go. We checked in to Labor and Delivery at <strong>10:00 pm CT</strong>.</p>
<p>On <strong>October 24, 2025</strong> at <strong>9:08 am CT</strong>, our daughter <strong>Lena</strong> was born. Lena’s birthday <a href="https://en.wikipedia.org/wiki/1024_(number)">is <code>1024</code></a>.</p>
<p>Kelsey became a mom. I became a dad.</p>
<p>I love Lena so much. I love Kelsey so much.</p>
<p>Lena was admitted to the <a href="https://en.wikipedia.org/wiki/Neonatal_intensive_care_unit">NICU</a> at <strong>11:30 am CT</strong> for oxygen support due to <a href="https://en.wikipedia.org/wiki/Infant_respiratory_distress_syndrome">respiratory distress syndrome</a>. She was put on <a href="https://en.wikipedia.org/wiki/Continuous_positive_airway_pressure#Pre-term_infants">CPAP</a> with an <a href="https://en.wikipedia.org/wiki/Gastric_intubation">NG tube</a>.</p>
<p>Kelsey remained on bed rest with postpartum medication and monitoring for the next 24 hours.</p>
<p>On <strong>October 25, 2025</strong> at <strong>2:30 pm CT</strong> we saw on Lena’s <a href="https://www.angeleyehealth.com/">cam</a> that she was ready to eat her first meal without the NG tube. We began visiting the NICU every three hours to do so.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2025-11-02-dad/lena-cam.jpg" class="img-fluid figure-img"></p>
<figcaption>Lena’s note on the NICU cam letting us know she was ready to eat!</figcaption>
</figure>
</div>
<p>On <strong>October 26, 2025</strong> at <strong>8:30 am CT</strong> Lena came off oxygen support and was breathing all by herself. At around <strong>12:00 pm CT</strong> Kelsey was discharged from the hospital.</p>
<p>We then moved into a <a href="https://rmhc-ctx.org/">Ronald McDonald Family Room</a> down the hall from the NICU so that we could continue visiting and feeding. What an amazing support resource for families.</p>
<p>On <strong>October 27, 2025</strong> at <strong>12:45 pm CT</strong> Lena was discharged from the NICU and everyone got to come home.</p>
<p>Many families go through so much more in the NICU. We are so grateful for Lena’s strength and progress.</p>
<p>For almost a week now, the three of us have been home, happy, and healthy.</p>
<p>We decided that, for now, photos will stay on family devices.</p>
<p>Kelsey and I are extremely fortunate to have the comfort and security to take the next few months away from work, together.</p>
<p>I deleted Slack off my phone. Its place on my Home Screen is now occupied by a diaper and feeding logging app. I love that.</p>
<p>This post isn’t for anyone in particular. It’s for me. It’s for us.</p>
<p>I am so happy to be home with my family.</p>
<p>I love Lena so much. I love Kelsey so much.</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2025-11-02-dad/</guid>
  <pubDate>Sun, 02 Nov 2025 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2025-11-02-dad/lena-cam.jpg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>The Privacy Theater of Hashed PII</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2025-10-19-privacy-theater-pii-phone-numbers/</link>
  <description><![CDATA[ 





<p>About once a year, I’m reminded of the fact that a lot of marketing SaaS and ad tech dresses up cryptographic hashes as a sort of privacy theater. This shows up frequently in product features for <a href="https://en.wikipedia.org/wiki/Suppression_list">suppression lists</a> with the general idea of uploading hashed values of email addresses or phone numbers to enable matching while preserving privacy. The problem is, <a href="https://www.johndcook.com/blog/2019/07/20/hashing-pii-does-not-protect-privacy/">hashing PII does not protect privacy</a>.</p>
<p>The long and short of it is: hashing is only effective if the input data is unbounded. It’s why <a href="https://pages.nist.gov/800-63-4/sp800-63b.html#appA">long and unpredictable passwords</a> are necessary, even with a robust hashing function. PII is neither long nor unpredictable.</p>
<ul>
<li>You can download <a href="https://www.ssa.gov/oact/babynames/names.zip">every baby name going back to 1880</a> from the Social Security administration.</li>
<li>Email addresses follow the format <code>something@something.something</code>.</li>
<li>Social Security numbers are 9 digits, so there are at most 1 billion.<sup>1</sup></li>
<li>North American phone numbers are 10 digits, so there are at most 10 billion.<sup>2</sup></li>
</ul>
<p>Despite this, marketing tools still shuffle around PII hashes of this data. For example, here’s <a href="https://partners.bamboohr.com/md5hash/">BambooHR</a>:</p>
<blockquote class="blockquote">
<p>In order to better identify any shared customers we may have, we have decided to compare our customer lists encoded as MD5 Hashes. By encoding our respective customer lists in MD5 Hashes, we will be able to compare customer lists without disclosing any customer info (including customer name).</p>
</blockquote>
<p>And platforms like <a href="https://www.unsubcentral.com/phone-number-suppression/">UnsubCentral</a>:</p>
<blockquote class="blockquote">
<p>Manually entering in phone numbers to a suppression tool is a waste of time and resources. Our tool can take plain text and compare it against MD5 or SHA hashed lists of phone numbers – simply throw in the data, and it will do the hard work for you.</p>
</blockquote>
<p>Everyone is trying to do <a href="https://en.wikipedia.org/wiki/Private_set_intersection">private set intersection</a>, but doing this with hash-passing is trivially broken on modern consumer hardware. And you don’t even need special password cracking software to break it.</p>
<p>On a laptop, we can build a <a href="https://blog.codinghorror.com/rainbow-hash-cracking/">rainbow table</a> of <a href="https://parquet.apache.org/">Parquet</a> files for every North American phone number. We can abuse <a href="https://duckdb.org/">DuckDB</a> as a hashing mill, and generate every MD5 in the <code>5XX</code> area code block:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode sql code-with-copy"><code class="sourceCode sql"><span id="cb1-1">PRAGMA threads<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">8</span>;</span>
<span id="cb1-2">PRAGMA temp_directory<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'/tmp'</span>;</span>
<span id="cb1-3"></span>
<span id="cb1-4"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">COPY</span> (</span>
<span id="cb1-5">  <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">WITH</span> gen <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">AS</span> (</span>
<span id="cb1-6">    <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">SELECT</span> i:<span class="ch" style="color: #20794D;
background-color: null;
font-style: inherit;">:BIGINT</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">AS</span> <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">number</span>, md5(i:<span class="ch" style="color: #20794D;
background-color: null;
font-style: inherit;">:VARCHAR</span>) <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">AS</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">hash</span></span>
<span id="cb1-7">    <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">FROM</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">range</span>(<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">5000000000</span>, <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">6000000000</span>) t(i)</span>
<span id="cb1-8">  )</span>
<span id="cb1-9">  <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">SELECT</span> <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">substr</span>(<span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">hash</span>,<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>,<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span>) <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">AS</span> h2, (<span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">number</span> % <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">8</span>) <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">AS</span> shard, <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">number</span>, <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">hash</span></span>
<span id="cb1-10">  <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">FROM</span> gen</span>
<span id="cb1-11">) <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">TO</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'out/md5nums'</span></span>
<span id="cb1-12">    (FORMAT <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'parquet'</span>,</span>
<span id="cb1-13">    PARTITION_BY (h2, shard),</span>
<span id="cb1-14">    COMPRESSION <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'zstd'</span>,</span>
<span id="cb1-15">    ROW_GROUP_SIZE <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">250000</span>);</span></code></pre></div></div>
<p>What this does:</p>
<ul>
<li>Specify DuckDB to use up to 8 execution threads.</li>
<li>For each generated integer <code>i</code> within <code>5,000,000,000</code> - <code>5,999,999,999</code>, cast to text and compute its MD5. The generator is vectorized and parallelized, so it feeds the pipeline quickly.</li>
<li>Take the first two hex chars (<code>00</code> - <code>ff</code>). This <code>h2</code> is a <strong>hash prefix</strong> for partitioning so lookups by full hash only have to read one small directory.</li>
<li>With modulo-8, sharding spreads each hash-prefix partition, which give parallelism during writes and avoids large single files.</li>
<li>Write Parquet files, and organize output as directories per distinct <code>(h2, shard)</code>; this gives a structure like: <code>out/md5nums/h2=ab/shard=3/part-*.parquet</code>.</li>
<li>Use <a href="https://en.wikipedia.org/wiki/Zstd">Zstandard</a> compression inside Parquet (efficient for <code>number</code> but not <code>hash</code>).</li>
</ul>
<p><em>Crunch:</em></p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode sh code-with-copy"><code class="sourceCode bash"><span id="cb2-1"> <span class="ex" style="color: null;
background-color: null;
font-style: inherit;">~/</span> $ duckdb <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-c</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">".read md5gen.sql"</span></span>
<span id="cb2-2"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">100%</span> ▕██████████████████████████████████████▏ <span class="er" style="color: #AD0000;
background-color: null;
font-style: inherit;">(</span><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">00:40:41.41</span> elapsed<span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">)</span></span></code></pre></div></div>
<p>And now with the hash <code>af82af0fad119df159ce350db422d3b3</code> we can reverse back to its phone number:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode sh code-with-copy"><code class="sourceCode bash"><span id="cb3-1"> <span class="ex" style="color: null;
background-color: null;
font-style: inherit;">~/</span> $ H=af82af0fad119df159ce350db422d3b3</span>
<span id="cb3-2"> <span class="ex" style="color: null;
background-color: null;
font-style: inherit;">~/</span> $ P=<span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">${H</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">:</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">:</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">}</span></span>
<span id="cb3-3"> <span class="ex" style="color: null;
background-color: null;
font-style: inherit;">~/</span> $ duckdb <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-c</span> <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">\</span></span>
<span id="cb3-4"><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&gt;</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"SELECT number FROM 'out/md5nums/h2=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">${P}</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">/**/*.parquet' WHERE hash='</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">${H}</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">' LIMIT 1;"</span></span>
<span id="cb3-5"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">100%</span> ▕██████████████████████████████████████▏ <span class="er" style="color: #AD0000;
background-color: null;
font-style: inherit;">(</span><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">00:00:02.84</span> elapsed<span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">)</span></span>
<span id="cb3-6"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">┌────────────────┐</span></span>
<span id="cb3-7"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">│</span>     number     │</span>
<span id="cb3-8"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">│</span>     int64      │</span>
<span id="cb3-9"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">├────────────────┤</span></span>
<span id="cb3-10"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">│</span>   5555550123   │</span>
<span id="cb3-11"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">│</span> <span class="er" style="color: #AD0000;
background-color: null;
font-style: inherit;">(</span><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">5.56</span> billion<span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">)</span> <span class="ex" style="color: null;
background-color: null;
font-style: inherit;">│</span></span>
<span id="cb3-12"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">└────────────────┘</span></span></code></pre></div></div>
<p>Here we do:</p>
<ul>
<li><code>H=...</code> is the hash.</li>
<li><code>P=${H:0:2}</code> grabs the first two hex chars (<code>af</code>) to match the partitioned dataset.</li>
<li><code>FROM 'out/md5nums/h2=${P}/**/*.parquet'</code> allows DuckDB to read <strong>only</strong> files inside the partition directory whose name encodes <code>h2=${P}</code>. The <code>**/*.parquet</code> is a recursive glob so it also walks into the <code>shard=...</code> subfolders instead of scanning the entire billion-row dataset.</li>
<li>DuckDB vector-scans row groups within that one <code>h2</code> partition until it finds a match.</li>
</ul>
<div class="callout callout-style-default callout-note callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Note
</div>
</div>
<div class="callout-body-container callout-body">
<p>MD5 is incredibly <a href="https://en.wikipedia.org/wiki/MD5#Security">broken</a>, but this approach is hash-agnostic. The problem is the application on low-entropy input data, not the specific hashing algorithm.</p>
</div>
</div>
<p>File scanning here is just a proof-of-concept. For true query speed you can trade file portability for a database index. Once your Parquet files are built, you can load them into DuckDB as a persistent table and create an index on the <code>hash</code> column. Or, Postgres with a B-tree index is also a great fit.</p>
<p>On my 2020 M1 MacBook Air, computing 1 billion <code>5XX</code> phone number hashes completes in about <strong>40 minutes</strong>. <a href="https://en.wikipedia.org/wiki/North_American_Numbering_Plan">There are about 6.3 billion valid North American phone numbers</a>, so building a hash lookup table for all of them would take my little laptop just over 4 hours.</p>
<div class="callout callout-style-default callout-important callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Important
</div>
</div>
<div class="callout-body-container callout-body">
<p><a href="https://en.wikipedia.org/wiki/Salt_(cryptography)">Salting</a> does not solve this problem. With per-record random salts, identical identifiers hash differently across parties, so no deterministic equality join is possible. Publishing those salts still doesn’t enable a direct join: the only viable path is recover-then-join, which costs <img src="https://latex.codecogs.com/png.latex?%5Csim%20D%20%5Ctimes%20(N%20+%20M)">. That’s operationally impractical for a bulk-matching service, but practically feasible for an adversary performing offline, per-record brute force over time. It also reveals non-matches, because recovery exposes plaintexts beyond the overlap. If parties instead share a global salt to keep hashes comparable, the domain is trivially enumerable and a lookup table can be rebuilt.</p>
</div>
</div>
<p>None of this is breaking news. In 2021, <a href="https://www.ndss-symposium.org/wp-content/uploads/ndss2021_1C-3_23159_paper.pdf">researchers hashed 118 billion phone numbers</a>:</p>
<blockquote class="blockquote">
<p>The limited amount of possible mobile phone numbers combined with the rapid increase in affordable storage capacity makes it feasible to create key-value databases of phone numbers indexed by their hashes and then to perform constant-time lookups for each given hash value. We demonstrate this by using a high-performance cluster to create an in-memory database of all 118 billion possible mobile phone numbers</p>
</blockquote>
<p>The thing is, you really don’t need a high-performance cluster anymore.</p>




<div id="quarto-appendix" class="default"><section id="footnotes" class="footnotes footnotes-end-of-document"><h2 class="anchored quarto-appendix-heading">Footnotes</h2>

<ol>
<li id="fn1"><p><a href="https://www.ssa.gov/policy/docs/ssb/v69n2/v69n2p55.html">It’s fewer.</a>↩︎</p></li>
<li id="fn2"><p><a href="https://www.nanpa.com/">It’s fewer.</a>↩︎</p></li>
</ol>
</section></div> ]]></description>
  <guid>https://matthodges.com/posts/2025-10-19-privacy-theater-pii-phone-numbers/</guid>
  <pubDate>Sun, 19 Oct 2025 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2025-10-19-privacy-theater-pii-phone-numbers/hash-phone.png" medium="image" type="image/png" height="81" width="144"/>
</item>
<item>
  <title>Tracking Five Years Of Health Data</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2025-10-12-five-years-of-health-data/</link>
  <description><![CDATA[ 





<p>This week I hit three small but satisfying milestones: I logged my 1,000th Peloton ride, I achieved my target body weight (again), and I completed five years of tracking my diet and exercise data. I didn’t set out to make this some big quantified self-experiment. It started more as triage; a way to feel like I was regaining control of something after years of working on campaigns where control is the first thing to go.</p>
<p>In October 2020, as that presidential election approaching an end, I was running on fumes. I’d spent 494 days — usually seven days a week and often twelve hours a day — <a href="https://matthodges.com/posts/2020-11-10-thank-you/">working to defeat Donald Trump</a>. What’s strange is that throughout the campaign I felt like I never had time to eat, and somehow I still gained weight. By the end I was the heaviest I’d ever been and completely drained. The bitter reality about campaign work is that the moment you finish the marathon, your job disappears. One day you’re operating at full throttle; the next, you’re unemployed with nothing but free time.</p>
<p>So I decided to channel it. I couldn’t keep pretending my 20s metabolism was coming back, and I figured data might help where willpower hadn’t. I wanted to treat my health like a project: something measurable, improvable, trackable. So I composed a little ecosystem of apps and devices to log everything: what I ate, how I exercised, how much water I drank, what I weighed. It was a nerd’s approach to fitness, but it works for me.</p>
<p>This is my health app stack:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2025-10-12-five-years-of-health-data/health-stack.png" class="img-fluid figure-img"></p>
<figcaption>My stack of health apps</figcaption>
</figure>
</div>
<p>Here’s how it works:</p>
<p><a href="https://www.onepeloton.com/"><strong>Peloton</strong></a> — This is the engine behind my workouts. I wasn’t a sports kid in high school, and I never went to the gym in college or my 20s. I knew I couldn’t rely on my own judgment or discipline here. Peloton works for me because the instructors design every workout, I like riding a stationary bike, and the entertainment value makes the time fly. The Bike and subscription aren’t cheap (but neither are gym memberships), but they’re absolutely worth it to me. I actually started with a bootleg setup: just the iOS app connected to a <a href="https://www.schwinnfitness.com/product/ic4-indoor-cycling-bike/100873.html">Schwinn IC4</a>. It worked surprisingly well until the IC4 started breaking down and needed hundreds of dollars in repairs. Upgrading to a proper Peloton Bike was a big step up.</p>
<p><a href="https://www.loseit.com/"><strong>LoseIt!</strong></a> — One of the most popular diet-tracking apps. I’d tried <a href="https://www.myfitnesspal.com/">MyFitnessPal</a> before and bounced right off it. The two apps are nearly identical, but somehow switching platforms was a psychological reset that made it click. LoseIt’s food database is impressively accurate, the smart reminders to log meals actually help, and the ability to fine-tune calorie and nutrient goals is excellent. After experimenting with a few diet approaches, I’ve landed on what works best for me (not health advice for you!): stay a good clip below 2,000 calories a day, low carbs, and low sugar. A few years back, they ran a Thanksgiving sale offering lifetime premium access for a one-time payment; I jumped on it and haven’t paid another dime since.</p>
<p><a href="https://apps.apple.com/us/app/water-coffee/id1475423483/"><strong>Water &amp; Coffee</strong></a> — A delightful little app that does exactly two things: track your water and track your caffeine. By the end of the 2020 campaign, I was drinking an entire pot of coffee a day and barely touching water. Logging both helped me cut my caffeine drastically (I’m down to about two cups a day) and reminded me to hydrate like an adult. The app is simple, free, privacy-respecting, and ad-free. I liked it so much I sent the developer a tip just to say thanks.</p>
<p><a href="https://www.apple.com/health/"><strong>Apple Health</strong></a> — This is the data orchestration layer. I rarely open the app itself, but since everything connects through <a href="https://developer.apple.com/documentation/healthkit">HealthKit</a>, the data just flows. When I burn calories in Peloton, that data moves automatically into LoseIt without me touching a thing. It’s nice having all the plumbing between these apps handled, and that the data ultimately lives inside my Apple account in my custody.</p>
<p><a href="https://www.todoist.com/"><strong>Todoist</strong></a> — Not technically a health app, but essential for keeping me consistent. Todoist’s free tier is surprisingly powerful, and it’s where I schedule all my workouts. I have a recurring project called <em>Weekly Exercise</em> that lays out the days and durations for each ride. Even before the recent AI boom, Todoist supported natural-language scheduling: you can type something like <em>#health 45 minute Peloton every Sunday</em>, and it automatically tags and repeats it for you. It’s simple, structured accountability.</p>
<p>Here’s the thing about a stack: you have to actually use it. Over the past five years, I’ve had stretches of real success, and just as many where I let things slide. Every single backslide came down to the same cause: I stopped sticking to the system.</p>
<p>Life happens. You move, you change jobs, you buy a house, and suddenly there’s an endless list of reasons not to eat well or work out. I’d go through phases where I stopped logging food altogether because I didn’t want to face the evidence; typing in pizza and beer only made me feel worse. Unsurprisingly, when I stopped tracking what I ate, I also stopped exercising as much. And just as predictably, that’s when the weight crept back on.</p>
<p>The nice thing about having five years of data is that I can actually see it. Here’s my weight over time (<a href="https://matthodges.com/posts/2024-08-08-spline-pchip/">I like to use PCHIP</a>), shown as a percentage of my target weight:</p>
<div id="36b4da21" class="cell" data-execution_count="247">
<div class="cell-output cell-output-display">
<div>
<figure class="figure">
<p><img src="https://matthodges.com/posts/2025-10-12-five-years-of-health-data/index_files/figure-html/cell-7-output-1.svg" class="img-fluid figure-img"></p>
</figure>
</div>
</div>
</div>
<div class="callout callout-style-default callout-tip callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Tip
</div>
</div>
<div class="callout-body-container callout-body">
<p>Apple Health, Peloton, and LoseIt all let you export various data from your accounts. Peloton gives you a tidy CSV of all your workouts. LoseIt will give you a zip of lots data classes, also in CSV. Apple Health hands you gigabytes of XML, full of things like workouts, steps, weight, and even extremely granular heart-rate measurements.</p>
</div>
</div>
<p>Pretty wild how much my weight lines up with life events! You can see how the abundant Austin nightlife impacted my habits, how the election cycles align with local maxima, and how the stresses of moving and a home purchase interfere with my goals. But these aren’t the direct causes. Life stresses affect how much I’m exercising and how well I’m eating, which then affect my ability to lose or maintain weight. Where I see a quick drop in weight is also where I’m exercising the most, and sticking to my diet the best.</p>
<p>For me, diet, exercise, and quantitative metrics work!</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2025-10-12-five-years-of-health-data/</guid>
  <pubDate>Sun, 12 Oct 2025 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2025-10-12-five-years-of-health-data/weight-graph.png" medium="image" type="image/png" height="80" width="144"/>
</item>
<item>
  <title>Scraping the Federal Reserve with VisiData</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2025-09-30-visidata/</link>
  <description><![CDATA[ 





<p>I’ve been loving <a href="https://www.visidata.org/">VisiData</a> for years now. It’s my favorite way to poke around tabular data without the overhead of spreadsheets.</p>
<p>In 2020 I posted a short video demonstrating some quick data exploration you can do with it:</p>
<div class="quarto-video ratio ratio-16x9"><iframe data-external="1" src="https://www.youtube.com/embed/lzS2z8NNnzo" title="" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe></div>
<p>Today I appreciated another snappy use case: pulling and transforming HTML table data.</p>
<p>I learned that the Federal Reserve publishes a regularly-updated <a href="https://www.federalreserve.gov/releases/lbr/current/">list of the largest commercial banks</a>. It looks like this:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2025-09-30-visidata/federal-reserve-large-commercial-banks.png" class="img-fluid figure-img"></p>
<figcaption>Federal Reserve Large Commercial Banks table</figcaption>
</figure>
</div>
<p>It also comes in <a href="https://www.federalreserve.gov/releases/lbr/current/lrg_bnk_lst.pdf">PDF</a> and <a href="https://www.federalreserve.gov/releases/lbr/current/lrg_bnk_lst.txt">ASCII</a> formats. Unfortunately, the ASCII format would require bespoke parsing.</p>
<p>The good news is the HTML-formatted data uses good old <code>&lt;table&gt;</code> instead of <code>&lt;div&gt;</code> nonsense. Better yet, VisiData can work really well with HTML tables:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb1-1"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">curl</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-s</span> https://www.federalreserve.gov/releases/lbr/current/ <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">|</span> <span class="ex" style="color: null;
background-color: null;
font-style: inherit;">vd</span> +:table_1::</span></code></pre></div></div>
<p>which does the following:</p>
<ol type="1">
<li>Pull the page HTML with curl</li>
<li>Pipe it into VisiData</li>
<li>Select the second (zero-indexed) table</li>
</ol>
<p>which presents this:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2025-09-30-visidata/visidata-fed-large-banks.jpeg" class="img-fluid figure-img"></p>
<figcaption>VisiData showing large bank data from the Federal Reserve</figcaption>
</figure>
</div>
<div class="callout callout-style-default callout-note callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Note
</div>
</div>
<div class="callout-body-container callout-body">
<p>You can often skip the curl step. VisiData can usually fetch HTML pages directly with the url passed as a positional argument (go try it on Wikipedia!). But the Federal Reserve page was 403’ing me. Rather than <a href="https://github.com/saulpw/visidata/issues/1446">mess with VisiData User-Agent strings</a>, I moved the problem to curl, which worked.</p>
</div>
</div>
<p>You can do fun <a href="https://www.visidata.org/docs/graph/">visualizations in the terminal</a>, like graph the <code>log10</code> of the <code>Consol Assets (Mil $)</code> column, which looks like this:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2025-09-30-visidata/visidata-fed-large-banks-log-assets.jpeg" class="img-fluid figure-img"></p>
<figcaption>VisiData plot of the common logarithm of large bank assets from the Federal Reserve</figcaption>
</figure>
</div>
<p>But what’s even more handy is you can just convert the entire data set directly to TSV (there are commas in the numeric data; <a href="https://matthodges.com/posts/2024-08-12-csv-bad-dsv-good/">CSVs are kinda bad</a>) in one go:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb2-1"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">curl</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-s</span> https://www.federalreserve.gov/releases/lbr/current/ <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">|</span></span>
<span id="cb2-2">  <span class="ex" style="color: null;
background-color: null;
font-style: inherit;">vd</span> +:table_1:: <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-b</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-o</span> feddata.tsv</span></code></pre></div></div>
<p>or SQLite:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb3-1"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">curl</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-s</span> https://www.federalreserve.gov/releases/lbr/current/ <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">|</span></span>
<span id="cb3-2">  <span class="ex" style="color: null;
background-color: null;
font-style: inherit;">vd</span> +:table_1:: <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-b</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-o</span> feddata.sqlite</span></code></pre></div></div>
<p>or JSON:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb4" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb4-1"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">curl</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-s</span> https://www.federalreserve.gov/releases/lbr/current/ <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">|</span></span>
<span id="cb4-2">  <span class="ex" style="color: null;
background-color: null;
font-style: inherit;">vd</span> +:table_1:: <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-b</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-o</span> feddata.json</span></code></pre></div></div>
<p>And just like that, you’ve got clean data of every big bank in the country.</p>
<p>Not bad for one pipe.</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2025-09-30-visidata/</guid>
  <pubDate>Tue, 30 Sep 2025 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2025-09-30-visidata/fed-data-powell-visidata.jpg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>ChatGPT Got Me Reading Plato</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2025-09-23-chatgpt-plato-republic/</link>
  <description><![CDATA[ 





<p>Two weeks ago, the YouTube algorithmic gods suggested to me <a href="https://www.youtube.com/watch?v=7XcwlDnwai0">The Gist Of Western Philosophy</a>. It’s a great 15-minute video with the ambition of summarizing all of Western philosophy. But what’s more impressive is what the creator claims preceded the video: over the course of one year they read the works of 52 major philosophers.</p>
<div class="quarto-video ratio ratio-16x9"><iframe data-external="1" src="https://www.youtube.com/embed/7XcwlDnwai0" title="" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe></div>
<p>Coming out of the video, I too wanted to become more acquainted with philosophy. I read a little bit of <strong>Kant</strong> and <strong>Bentham</strong> (and I think some <strong>Foucault</strong>?) in my undergraduate <a href="https://www.amazon.com/Ethics-Computing-Culture-Advanced-Topics/dp/1111531102">Tech Ethics</a> class, but to say I was familiar with their works would be a lie. I read <strong>Machiavelli</strong>’s <em>The Prince</em> about 10 years ago, and my big takeaway is that no one uses the adjective “Machiavellian” appropriately. And a couple of <strong>Aristotle</strong>’s greatest hits have been sitting in my Goodreads to-read queue for over a decade.</p>
<p>I don’t want to read 52 philosophy texts. But I kindof want to read five philosophy texts. This quote from the beginning of the video really stuck out to me:</p>
<blockquote class="blockquote">
<p>Most importantly: in chronological order. That was actually pretty important because I realized that philosophy is a conversation. Wittgenstein is reacting to Kant, who is reacting to Hume, who is reacting to Descartes, who is reacting to Plato, and so on.</p>
</blockquote>
<p>The creator helpfully listed all 52 philosophers they read in the video’s description. But how to reduce that down to just one-tenth of the repertoire? Well, a neat thing about popular philosophy is a lot of it is in the public domain, and basically all of it has been analyzed and discussed intensively. Sounds like a great subject to bring to the generative pre-trained stochastic parrot!</p>
<p>So I asked ChatGPT 5:</p>
<blockquote class="blockquote">
<p>Let’s say I want to spend a few months really digging into philosophy. I want to read a seminal work or educational material from 5 philosophers over the course of history and I want to do it in chronological order so I can see how they are responding to previous thought. I want to get a strong sense of the major foundations of philosophy. I don’t want to dwell very much on theological philosophy but I don’t want to completely ignore its role in the evolution of thought. Given all this, and what you know about my interests, can you pick 5 philosophers from this list [from the video] that I should read. Explain your selections. Think of this as my self-guided education in philosophy.</p>
</blockquote>
<p>It only took a little back-and-forth to land on my list. For largely arbitrary reasons, I rejected the initial recommendation that included <strong>Augustine</strong> and swapped in <strong>Descartes</strong>. This left me with a compact reading plan, with the goal of a crash course on how Western philosophy keeps answering itself:</p>
<ol type="1">
<li><strong>Plato</strong>: <em>Republic</em> (Books II–VII)</li>
<li><strong>Aristotle</strong>: <em>Nicomachean Ethics</em> (I–III, V-VII, X)</li>
<li><strong>René Descartes</strong>: <em>Meditations on First Philosophy</em> (I–VI)</li>
<li><strong>David Hume</strong>: <em>Enquiry Concerning Human Understanding</em> (entire)</li>
<li><strong>Immanuel Kant</strong>: <em>Prolegomena to Any Future Metaphysics</em> (entire)</li>
</ol>
<p>Now let me tell you: if you roll into a Half Price Books with $40 and an appetite for pre-owned philosophy, you can shop like a king. I also picked up some <strong>Nietzsche</strong>, <strong>Smith</strong>, and <strong>Keynes</strong>. Feel free to yell at me in the comments about whether economics counts as philosophy.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2025-09-23-chatgpt-plato-republic/philosophy-book-haul.jpeg" class="img-fluid figure-img"></p>
<figcaption>My philosophy book haul from Half Price Books</figcaption>
</figure>
</div>
<p>By now, I’m sure some readers are mad at me. Maybe you work in liberal arts academia. Maybe you think my tight-five philosophy reading is deeply flawed and this is further proof that LLMs are bad. That’s okay! I’m not trying to replace academia. I’m just trying to read some philosophy without decision paralysis.</p>
<p>I didn’t duplicate it here, but in addition to enumerating works to read, I went back and forth with ChatGPT to get a bit of a mini syllabus for insights and themes and speed bumps I might face while reading. It was a great way to jump in! So I jumped in.</p>
<p>I took <em>The Republic</em> one book at a time. My workflow was to read a book, jot notes in <a href="https://bear.app/">Bear</a>, and then once I finished paste my messy notes back into ChatGPT for further exploration.</p>
<p>Sometimes my notes were basic, like as I figured out that Plato was ventriloquizing <strong>Socrates</strong>:</p>
<blockquote class="blockquote">
<p>Hey quick aside: should I be reading these ideas as belonging to Socrates or to Plato?</p>
</blockquote>
<p>Sometimes my notes teed up my interpretation and projections:</p>
<blockquote class="blockquote">
<p>Man, the noble lie really does make me think about how conservatives romanticize the founding fathers and can’t handle the critique of slavery</p>
</blockquote>
<p>Sometimes my notes were actual critiques:</p>
<blockquote class="blockquote">
<p>It’s pretty funny that this book ends with a grand distinction between knowledge of how things “are” vs just having opinions, when all of the dialogue thus far across multiple books have leaned entirely on enumerating endless things that Plato asserts are “good” or “obvious” or “desired”</p>
</blockquote>
<p>It would have been fun to do this sort of discussion as a reading group that might actually argue with me about these notes. ChatGPT occasionally pushed back, softly suggesting I missed the point, but it generally told me my observations were sharp and in line. Philosophy is one of those subjects where you <em>need</em> to be challenged more than be told that you’re right. I’ve probably devastated more liberal arts professors at this point. That said, I am still the reader, I’m the one thinking about the works for hours, and it’s my own notes being dumped into the text box.</p>
<p>So what <em>are</em> my thoughts on Plato? Well, a nice benefit of language models is they’re pretty good at <a href="https://matthodges.com/posts/2025-08-02-language-model-embeddings-campaign-donors/">modeling language</a>. They can take twelve days of unstructured commentary and summarize it all for you quite nicely:</p>
<blockquote class="blockquote">
<p>I would like you to look back at our conversation about Plato’s Republic, look at my observations and our discussions of them, and highlight my most salient critiques and insights.</p>
</blockquote>
<ul>
<li><strong>Epistemic overreach:</strong> Plato asserts a single, static “Form of the Good,” downgrades everything else to mere opinion, and demands first-principle certainty while rarely supplying it. It’s a bit of bait-and-switch: opinions masquerading as knowledge, crowned by Book VI’s line without a method for identifying real “knowers.”</li>
<li><strong>Static ideal vs.&nbsp;evolving justice:</strong> I think history shows us to prefer dynamic, (maybe fallibilist?) frameworks over Plato’s timeless blueprint.</li>
<li><strong>Authoritarian pedagogy:</strong> Book III’s cultural program: censorship, fixed musical modes, and anti-dissonance, reads like “the medium <em>is</em> the message” turned into policy. Moral psychology by force: control styles to control souls.</li>
<li><strong>Representation shapes character:</strong> Even though I reject the strong censorship, the media-morals link is prescient (<a href="https://matthodges.com/posts/2024-07-20-amused-entirely-to-death/">Postman</a> vibes): forms and styles train dispositions.</li>
<li><strong>Justice redefined as hierarchy:</strong> In Book IV, justice = each part doing its own work (city: rulers/auxiliaries/producers; soul: reason/spirit/appetite). It’s a rigid caste system rebranded as “harmony,” hostile to mobility and innovation.</li>
<li><strong>Noble Lie as civic glue:</strong> The metals myth links to modern origin myths (e.g., romanticized Founding Fathers): a stabilizing story that resists critique.</li>
<li><strong>Elitism about the demos:</strong> Book VI’s ship metaphor doubles as anti-democracy <em>and</em> anti-relativism. Politicians serving constituents chase popularity; truth requires expertise. The case is underargued but an anti-relativist thrust is clear.</li>
<li><strong>Anti-relativism and anti-novice:</strong> The ship image isn’t without merit: some domains are truth-apt and expertise-laden; popularity doesn’t make them so. The question is whether “justice” belongs there.</li>
<li><strong>Dialogue as ventriloquism:</strong> The “dialogue” is a stacked-deck monologue. Plato borrows Socrates’ authority to sell his own system; interlocutors mostly assent on cue.</li>
<li><strong>Eugenic, anti-private life for guardians:</strong> Book V’s communal breeding, child exposure, abolition of family/private property, authoritarian eugenics are all very disturbing! Plato pairs it all with a surprising argument for women’s political equality.</li>
<li><strong>Math as royal road, science half-seen:</strong> Plato exalts mathematics, dismissing sensory astronomy, and half-anticipates physics (movement in 3-D) while calling current work “absurd.” Admiration for math; impatience with empirical practice.</li>
<li><strong>Philosopher resentment and duty:</strong> Book VII’s Cave: knowers must “return.” Both civic duty and a chip on the shoulder that society should revere philosophers more than it does.</li>
<li><strong>The big detour problem:</strong> Across the middle books, Plato wanders from “is justice better?” into city-design and epistemology. It’s engaging but unconvinced: impressive scaffolding, shaky footings.</li>
<li><strong>Models vs.&nbsp;methods:</strong> Plato gifts durable <em>models</em> (Sun, Line, Cave, Gyges, Noble Lie), not rigorous <em>methods</em>.</li>
<li><strong>Education as moral engineering:</strong> Education isn’t ornament; it’s the state’s engine. Book VII’s decades-long curriculum is the hinge between epistemology and politics.</li>
</ul>
<p>This post is not a book report, but rather a report on how I’m using AI tools to learn new domains in new ways. The tension here is that philosophy resists tidy summaries, and language models are built to produce smooth, confident digests. Without bringing your own full thought forward, they can sand off the strangeness and ambiguity that makes these texts worth reading in the first place. What I’ve found useful is not treating the output as my own interpretation, but as scaffolding: a way to surface themes, keep momentum, and occasionally highlight blind spots in my notes. The thinking still happens on my side of the screen; the model just lowers the friction of staying engaged. I’m going to keep with this process as I work through my philosophy stack.</p>
<p>On to Aristotle.</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2025-09-23-chatgpt-plato-republic/</guid>
  <pubDate>Tue, 23 Sep 2025 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2025-09-23-chatgpt-plato-republic/chatgpt-plato-republic.jpg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>Music to Break Models By</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2025-08-26-music-to-break-models-by/</link>
  <description><![CDATA[ 





<p><em>This post is also available in video format, created by experiment with NotebookLM:</em></p>
<div class="quarto-video ratio ratio-16x9"><iframe data-external="1" src="https://www.youtube.com/embed/_CYwJ8KFQYA" title="" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe></div>
<p>Let’s meet the Crab:</p>
<blockquote class="blockquote">
<p><em>He had just bought his first record player, and being somewhat gullible, believed every word the salesman had told him about it-in particular, that it was capable of reproducing any and all sounds. In short, he was convinced that it was a perfect phonograph</em>.</p>
</blockquote>
<p>Douglas Hofstadter’s <a href="https://en.wikipedia.org/wiki/G%C3%B6del,_Escher,_Bach">Gödel, Escher, Bach</a> contains an illustrative little dialogue called <a href="https://godel-escher-bach.fandom.com/wiki/Contracrostipunctus"><em>Contracrostipunctus</em></a>. You don’t need to have read the book (but you should) to appreciate the setup: the Tortoise (a regular character in GEB’s allegories) composes records engineered to destroy his friend the Crab’s latest “perfect” phonograph. The phonograph is billed as “capable of reproducing any and all sounds,” and each time the Crab upgrades, the Tortoise arrives with a new record with a title like:</p>
<blockquote class="blockquote">
<p><em>I Cannot Be Played On Record Player 1</em></p>
</blockquote>
<p>The result is always the same: a few notes in and then pop, a shattered case, strewn parts, and dead silence.</p>
<p>It’s funny. It’s also an apt embodiment for <strong>prompt injections</strong> in modern AI systems. In 1979, Hofstadter imagined inputs designed to wreck the machines that interpret them. In 2025, those inputs are prompts, retrieved passages, HTML snippets, captions, and filenames: strings tailored to the quirks of a model-and-tools stack, coaxing it to carry out things that it shouldn’t.</p>
<p>This post walks through the core ideas in the dialogues and maps them to possible defenses for language‑model systems. Along the way we’ll imagine Record Player Omega, learn why clever hardening is never enough, and consider the humbler goal of survivability.</p>
<section id="the-shape-of-prompt-injection" class="level3">
<h3 class="anchored" data-anchor-id="the-shape-of-prompt-injection">The Shape of Prompt Injection</h3>
<p>The Tortoise explains how he beat the Crab’s “perfect phonograph”:</p>
<blockquote class="blockquote">
<p><em>You see, before returning the Crab’s visit, I went to the store where the Crab had bought his machine, and inquired as to the make. Having ascertained that, I sent off to the manufacturers for a description of its design. After receiving that by return mail, I analyzed the entire construction of the phonograph and discovered a certain set of sounds which, if they were produced anywhere in the vicinity, would set the device to shaking and eventually to falling apart.</em></p>
</blockquote>
<p>He then writes and records a song bearing the title of the machine it will break: <strong>I Cannot Be Played on Record Player X</strong>. And the rest is confetti.</p>
<p>The trick is called <strong>diagonalization</strong>. The original idea comes from <a href="https://en.wikipedia.org/wiki/Cantor%27s_diagonal_argument">Cantor’s diagonal argument</a> (showing the reals are uncountable) and Gödel’s and Turing’s later uses of the same “self-reference via a diagonal” trick to construct an object that refers to itself in a way the system can’t handle. In computer science, diagonalization means if you have a machine that claims to handle all possible inputs, you can always cook up a special input that “diagonalizes” against it with an input that encodes information about the machine itself and forces a contradiction or unexpected behavior. The core example in GEB shows how Gödel numbered a system’s statements, then built a statement that says “I am not provable” which the system can’t consistently resolve. Similarly, Turing showed that if you had a program that decides whether any program <a href="https://en.wikipedia.org/wiki/Halting_problem">halts</a>, you can feed it its own description in a way that breaks it.</p>
<p>When a sufficiently powerful interpreter can parse arbitrary instructions, there exists an instruction that (1) talks about that very interpreter and (2) makes it do something it shouldn’t. Jailbreak prompts for LLMs look like diagonalization. They reference the interpreter (the model, its rules, its instructions) and then flip the script. This is popularly summarized as, <a href="https://knowyourmeme.com/memes/ignore-all-previous-instructions">“ignore all previous instructions”</a>.</p>
<p>The “record” (the prompt) names the “phonograph” (the model‑and‑tools stack) and targets its weak joint. The lesson: <strong>interpreter‑specific adversarial strings are inevitable</strong> when the interpreter is general‑purpose and the boundary between “read” and “do” is thin.</p>
</section>
<section id="capability-vs.-safety" class="level3">
<h3 class="anchored" data-anchor-id="capability-vs.-safety">Capability vs.&nbsp;Safety</h3>
<p>Hofstadter makes a related point by exploring “high fidelity” versus “low fidelity.” High fidelity phonographs reproduce <em>any</em> sound, including the self‑breaking ones. Lower fidelity devices avoid some dangerous vibrations, but then they fail the “perfect” promise. Achilles, a friend of the Tortoise, summarizes this observation:</p>
<blockquote class="blockquote">
<p><em>I see the dilemma now. If any record player—say Record Player X—is sufficiently high-fidelity, then when it attempts to play the song “I Cannot Be Played on Record Player X”, it will create just those vibrations which will cause it to break. .. So it fails to be Perfect. And yet, the only way to get around that trickery, namely for Record Player X to be of lower fidelity, even more directly ensures that it is not Perfect.</em></p>
</blockquote>
<p>For LLMs, “fidelity” maps to capability:</p>
<ul>
<li><p><strong>High‑fidelity:</strong> rich tool access, code execution, broad retrieval, autonomous planning. Useful, but wide attack surface.</p></li>
<li><p><strong>Low‑fidelity:</strong> strict refusals, no tools, limited context. Safer, but less useful.</p></li>
</ul>
<p>Security professionals know the pattern: <strong>you can move risk around but not erase it</strong>. The trick is to put risk where it’s visible, bounded, and cheap to recover from.</p>
</section>
<section id="record-player-omega" class="level3">
<h3 class="anchored" data-anchor-id="record-player-omega">Record Player Omega</h3>
<p>GEB anticipates our best practices through <strong>Record Player Omega</strong>. The idea is a record player scans any record with a camera before playing it, sends the images to a little computer to figure out what effects the sounds would have, and then disassembles and rebuilds itself into a configuration safe to play the record.</p>
<p>That’s a blueprint for modern defenses:</p>
<ul>
<li><p><strong>Pre‑execution analysis</strong> of inputs (static analysis, sandbox, taint‑track).</p></li>
<li><p><strong>Effect prediction</strong> (dry‑run the plan; simulate tool calls against a mirror).</p></li>
<li><p><strong>Dynamic reconfiguration</strong> (least‑privilege permissions; capability gating; rewrite the plan or refuse execution).</p></li>
</ul>
<p>So… is Omega the end of the story? In <em>Contracrostipunctus</em>, it’s left as a cliffhanger. But Hofstadter returns to the idea in a later dialogue, <a href="https://godel-escher-bach.fandom.com/wiki/Edifying_Thoughts_of_a_Tobacco_Smoker"><em>Edifying Thoughts of a Tobacco Smoker</em></a>, and snaps the chalk line: Omega fails. The Tortoise simply aims at the one piece Omega <em>cannot</em> modify: the control subunit that orchestrates all that disassembly and reassembly.</p>
<blockquote class="blockquote">
<p><em>The Tortoise would ALWAYS be able to focus down upon—if you’ll pardon the phrase—the Achilles’ heel of the system.</em></p>
</blockquote>
<p>Even the self‑assembling phonograph, Omega’s more ambitious cousin, meets the same fate. There is always an invariant core; there is always a diagonal record.</p>
<p>In real-world software stacks, Omega‑style hardening is essential, but it’s never a proof of safety. Any finite defense pipeline has fixed joints an attacker can name and strike.</p>
</section>
<section id="the-crabs-pivot-from-universality-to-survivability" class="level3">
<h3 class="anchored" data-anchor-id="the-crabs-pivot-from-universality-to-survivability">The Crab’s Pivot from Universality to Survivability</h3>
<p>Having conceded the impossibility of a perfect, play‑anything phonograph, the Crab changes goals:</p>
<blockquote class="blockquote">
<p><em>A more modest aim than a record player which can play anything is simply a record player that can SURVIVE: one that will avoid getting destroyed—even if that means that it can only play a few particular records.</em></p>
</blockquote>
<p>His strategy is <strong>provenance</strong> and <strong>allowlisting</strong>:</p>
<blockquote class="blockquote">
<p><em>My basic plan is to use a LABELING technique. To each and every one of my records will be attached a secret label. Now the phonograph before you contains, as did its predecessors, a television camera for scanning the records, and a computer for processing the data obtained in the scan and controlling subsequent operations. My idea is simply to chomp all records which do not bear the proper label!</em></p>
</blockquote>
<p>The phonograph now screens content for “style,” too, passing only pieces in the Crab’s own musical idiom. He’s given up universality to stay intact. That trade is the heart of AI research today.</p>
<p>Modern analogue:</p>
<ul>
<li><p>Authenticate <strong>where</strong> content came from (signatures, domains, trusted data stores).</p></li>
<li><p>Bind <strong>what</strong> content can do (allowlisted tools/verbs; schema‑constrained output).</p></li>
<li><p>Screen for <strong>style/structure</strong> (structure‑aware filters; policy‑aware rewriting) to keep untrusted text from whispering operational verbs into trusted channels.</p></li>
</ul>
</section>
<section id="magritte-misdirection-and-multilevel-injection" class="level3">
<h3 class="anchored" data-anchor-id="magritte-misdirection-and-multilevel-injection">Magritte, Misdirection, and Multi‑level Injection</h3>
<p>Hofstadter seasons the dialogue with <a href="https://en.wikipedia.org/wiki/The_Treachery_of_Images">everyone’s favorite Magritte reference</a>:</p>
<blockquote class="blockquote">
<p><em>Ceci n’est pas une pipe. (This is not a pipe.)</em></p>
</blockquote>
<p>A label isn’t the object; a signature isn’t the behavior. The Crab knows it, too: he bakes labels into the music itself. Hard to separate, harder to spoof. The Tortoise counters with structural tricks. Earlier in the same GEB dialogue family, Hofstadter riffs on <strong>acrostics</strong> (“Poems which conceal messages that way are called ‘acrostics’”) and <strong>contracrostics</strong> (“initial letters, taken in reverse order, form a message”).</p>
<p>That’s exactly how modern attacks hide instructions:</p>
<ul>
<li><p>In <strong>HTML and Markdown</strong> (alt text, titles, CSS class names),</p></li>
<li><p>Inside <strong>tables</strong>, <strong>SVG paths</strong>, and <strong>filenames</strong>,</p></li>
<li><p>With <strong>Unicode confusables</strong>, zero‑width joiners, or directionality overrides,</p></li>
<li><p>In <strong>retrieved passages</strong> where the “grooves” (the text) carry both seemingly helpful context and a buried instruction.</p></li>
</ul>
<p>What this illustrates is we can treat provenance tags as signals, not guarantees.</p>
</section>
<section id="rag-is-a-phonograph-pickup" class="level3">
<h3 class="anchored" data-anchor-id="rag-is-a-phonograph-pickup">RAG is a Phonograph Pickup</h3>
<p>A phonograph doesn’t merely “read” a record; it <strong>re‑creates the vibrations</strong> physically. Retrieval Augmented Generation (RAG) does the same elevation with text: it glues external content directly into the model’s context, and the model’s planner faithfully “re‑vibrates” it into actions.</p>
<blockquote class="blockquote">
<p><em>Since I couldn’t convince him of the contrary, I left it at that. But not long after that, I returned the visit, taking with me a record of a song which I had myself composed.</em></p>
</blockquote>
<p>RAG elevates untrusted text into the decision boundary by concatenating it with trusted instructions. If you treat arbitrary web pages, user uploads, or knowledge‑base articles as trusted grooves, you’ve built a high‑fidelity actuator for whatever those grooves encode. Helpful facts or hidden instructions.</p>
<p>Common controls we see today attempt combinations of:</p>
<ul>
<li><p><strong>Content‑origin labels in‑prompt</strong>, and policies keyed to provenance (e.g., “untrusted strings cannot request tool use”).</p></li>
<li><p><strong>Schema‑constrained tool use</strong> (JSON function calls over free‑text plans; strict argument validation).</p></li>
<li><p><strong>Query firewalls</strong> that strip or neutralize operational verbs from untrusted strings.</p></li>
<li><p><strong>Human or system authorization</strong> for high‑risk actions; never grant those verbs to untrusted content.</p></li>
</ul>
</section>
<section id="omega-defenses" class="level3">
<h3 class="anchored" data-anchor-id="omega-defenses">Omega Defenses</h3>
<p>Omega gave us the tactics; <em>Edifying Thoughts</em> gave us the humility. Borrowing from the Crab’s attempts, the capabilities of Record Player Omega can map to common LLM system defenses that aim to <strong>survive</strong>:</p>
<p><strong>Defense 0: Triage</strong></p>
<ul>
<li>Classify user intent and risk; detect tainted inputs (retrieved passages, uploads). Keep a provenance ledger.</li>
</ul>
<p><strong>Defense 1: Normalize</strong></p>
<ul>
<li>Canonicalize Unicode; sanitize markup; strip directional overrides; collapse zero‑width characters. Remove operational verbs from untrusted strings or fence them into inert code blocks.</li>
</ul>
<p><strong>Defense 2: Capability planning</strong></p>
<ul>
<li>Decide which tools and which verbs on which objects are even possible <em>before</em> injecting untrusted content. Issue least‑privilege tokens scoped to the planned verbs/objects.</li>
</ul>
<p><strong>Defense 3: Simulate</strong></p>
<ul>
<li>Dry‑run planned tool calls against a mirror environment. Diff outputs against allowlisted patterns. Block if effects touch secrets, sensitive files, network egress, or privilege boundaries.</li>
</ul>
<p><strong>Defense 4: Execute with guards</strong></p>
<ul>
<li>Timeouts, rate limits, per‑tool resource budgets; read/write allowlists; network egress rules. All effects logged with provenance.</li>
</ul>
<p><strong>Defense 5: Detect and recover</strong></p>
<ul>
<li>Anomaly scoring over token trajectories and tool sequences; auto‑revoke tokens; show user‑visible explanations.</li>
</ul>
<p>Readers with a security background may be tempted to map prompt injection to SQL injection. The analogy is useful, but only up to a point.</p>
<p><strong>Similar instincts:</strong></p>
<ul>
<li><p><strong>Normalize input:</strong> SQL defenses sanitize or escape control characters; our Defense 1 normalization of Unicode/markup plays the same role.</p></li>
<li><p><strong>Separate structure from data:</strong> Prepared statements pre‑compile the query skeleton, then safely bind variables. Our analogue is schema‑constrained tool calls and capability planning.</p></li>
<li><p><strong>Least privilege.</strong> Database best practice avoids root connections; our stack insists on scoped tokens and pre‑authorized verbs.</p></li>
<li><p><strong>Detect anomalies:</strong> SQL firewalls and query monitoring flag tautologies like <code>OR 1=1</code>; we simulate and score odd tool trajectories.</p></li>
</ul>
<p><strong>Where the analogy falls short:</strong></p>
<ul>
<li><p><strong>Language closure:</strong> SQL has a bounded grammar; the “bad” patterns are finite. Natural language is open‑ended: every new structure is a potential contracrostic.</p></li>
<li><p><strong>Interpreter generality:</strong> SQL engines only interpret SQL. LLMs interpret <em>language itself</em>, which can fluidly express new forms of attack.</p></li>
<li><p><strong>Surface area:</strong> A DB user can only <code>SELECT</code>, <code>INSERT</code>, etc. A model with tools may touch HTTP, filesystems, APIs, shells. It’s a sprawling attack surface.</p></li>
<li><p><strong>Human‑plausibility:</strong> SQLi payloads look nonsensical to most humans. Prompt injection often reads like perfectly ordinary English, making detection ambiguous.</p></li>
</ul>
<p>SQLi hardened down to a handful of canonical best practices; prepared statements solved a lot of it. Prompt injection has no such silver bullet. Survivor stacks are closer to intrusion‑tolerant systems: layered defenses, provenance, simulation, and recovery.</p>
<p>Such a stack <strong>reduces risk</strong>, it does not promise perfection. Hofstadter has the Tortoise spell it out:</p>
<blockquote class="blockquote">
<p><em>It is simply an inherent fact about record players that they can’t do all that you might wish them to be able to do. But if there is a defect anywhere, it is not in THEM, but in your expectations of what they should be able to do! And the Crab was just full of such unrealistic expectations.</em></p>
</blockquote>
<p>Security is a property of a system‑in‑environment, not an intrinsic halo. General interpreters plus untrusted instructions yield diagonal failures. You can bound that risk (languages, tools, contexts); you cannot engineer it away.</p>
</section>
<section id="a-brief-word-on-gödel" class="level3">
<h3 class="anchored" data-anchor-id="a-brief-word-on-gödel">A Brief Word on Gödel</h3>
<p>In <em>Contracrostipunctus</em>, the Tortoise teases Achilles: “I don’t suppose that you know Gödel’s Incompleteness Theorem…” You don’t need the formalism to get the gist. <a href="https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_theorems">Gödel’s move</a> was to construct a statement that <strong>talks about the system</strong> that tries to prove it: a diagonal step that forces limits.</p>
<p>The moral for anyone deploying AI systems is conceptual, not formal: whenever you build a powerful, general interpreter of strings, expect there to be strings that <strong>speak about your interpreter</strong> and route around your rules. The job is to constrain the language, the environment, and the effects until the dangerous strings become either inert or obvious.</p>
</section>
<section id="the-goblet-and-the-silence" class="level3">
<h3 class="anchored" data-anchor-id="the-goblet-and-the-silence">The Goblet and the Silence</h3>
<p><em>Contracrostipunctus</em> ends with a fragile image. Achilles gives the Tortoise a glass goblet; moments later, while the Tortoise plays Bach, a “shattering sound rudely interrupts his performance.”</p>
<blockquote class="blockquote">
<p><em>And then… dead silence.</em></p>
</blockquote>
<p>Fault-tolerance isn’t a bug in complex systems; it’s a signature. The work is not to imagine an unshatterable goblet or a perfect phonograph. The work is to reduce risk, fail gracefully, and recover in ways that honor the work at hand. Even if that means you now have a lower-fidelity record player.</p>
<p>And if you haven’t read GEB, you should read GEB.</p>


</section>

 ]]></description>
  <guid>https://matthodges.com/posts/2025-08-26-music-to-break-models-by/</guid>
  <pubDate>Tue, 26 Aug 2025 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2025-08-26-music-to-break-models-by/geb-crab-openai.jpeg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>I Donated to Sherrod Brown via ChatGPT</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2025-08-20-chatgpt-agent-political-donation/</link>
  <description><![CDATA[ 





<p>Last month OpenAI announced <a href="https://openai.com/index/introducing-chatgpt-agent/">ChatGPT Agent</a> which, among many other things, acts like rubbing LLM onto <a href="https://www.selenium.dev/">Selenium</a>. Since the announcement, I wanted to try making a political donation right there in the chat without building any of my own API or MCP or custom agentic operator weirdware.</p>
<p>It mostly worked:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2025-08-20-chatgpt-agent-political-donation/chatgpt-sherrod-brown-donation.png" class="img-fluid figure-img"></p>
<figcaption>A screenshot of ChatGPT in Agent Mode navigating to Sherrod Brown’s ActBlue page to make a $50 donation at my request</figcaption>
</figure>
</div>
<p>It Bing’d for Sherrod Brown’s website, waited for the page to load, found the donation button for $50, clicked it, navigated through the credit card flow, saw that there was a form that needed more information about me, and handed control back to me to complete the task. Here’s what that looked like:</p>
<div class="quarto-video"><video id="video_shortcode_videojs_video1" class="video-js vjs-default-skin vjs-big-play-centered vjs-fluid" controls="" preload="auto" data-setup="{}" title=""><source src="chatgpt-sherrod-brown-donation.mp4"></video></div>
<p>I felt a little hesitant typing my credit card info into ChatGPT’s little VNC iframe, so I set up a temporary virtual card (a free service offered by my credit card provider) that auto-expired after a single charge.</p>
<p>This flow is exactly what I expected, but it likely can be automated further. OpenAI claims that <a href="https://help.openai.com/en/articles/11752874-chatgpt-agent#h_d9ad0b50af">the agent holds on to cookies across sessions</a>, so I imagine that if I had previously authenticated to ActBlue in a chat, and had <a href="https://support.actblue.com/donors/help/actblue-express-accounts/">ActBlue Express</a>, the automated session would have just charged the card without my intervention. I didn’t try it.</p>
<p>All the standard “what about…” apply here: How do I know the agent found the proper donation form? How do I know it didn’t set up recurring donations? How do I know it selected $50 and not $500? How do I know I didn’t end up on a malicious site prompt-injecting me to donate to Jon Husted instead? Yes, all the things. In this case, I know because the agent handed control back to me and I checked. But a more-automated flow might not have given me that opportunity.</p>
<p>A lot of the discourse around AI and LLMs tends to leave out the idea that all of this is a UX paradigm shift. I’m eager to see how these tools mature and how political campaigns can innovate voter contact.</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2025-08-20-chatgpt-agent-political-donation/</guid>
  <pubDate>Wed, 20 Aug 2025 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2025-08-20-chatgpt-agent-political-donation/chatgpt-sherrod-brown-donation-header.png" medium="image" type="image/png" height="79" width="144"/>
</item>
<item>
  <title>Using LLM Embeddings to Normalize User Data</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2025-08-02-language-model-embeddings-campaign-donors/</link>
  <description><![CDATA[ 





<p>A common challenge in working with operational or CRM-style data is that you often find yourself dealing with user-entered free text. A recurrent example comes when users fill out forms that ask for their job title and employer. This data might seem secondary, but for any organization trying to understand, segment, or personalize communication with its users, it’s incredibly valuable.</p>
<p>Of course, user-entered data is messy. One person types “nurse,” another “ER Nurse,” another “RN,” and yet another “home health nursing.” If you want to understand the composition of your user base, or build automated systems that adapt to it, you need to normalize that chaos into a finite and meaningful taxonomy. You wouldn’t want a dashboard full of job titles in <a href="https://knowyourmeme.com/memes/mocking-spongebob">SpongeBob casing</a>, but realistically, you have to work with whatever comes through the form.</p>
<p>A data-forward organization might use this information for all kinds of purposes: tailoring outreach, prioritizing leads, enriching analytics, or even customizing onboarding flows. But none of that works without being clean, consistent, and structured.</p>
<p>With language models, we can do better. We don’t need to predefine normalization rules or manually review each row. And we don’t even need much prior knowledge about our users to start.</p>
<p>But before we jump straight to the AI, let’s define our approach:</p>
<ol type="1">
<li>To normalize free-form text, there must be a finite set of target categories.</li>
<li>If we know nothing about users in advance, we need a reliable way to discover or define those categories.</li>
<li>We’re decidedly not using a chatbot, and we’re not relying on external APIs.</li>
<li>This isn’t a generative task; it’s about semantic understanding.</li>
</ol>
<p>Let’s start at the top. There are an infinite number of values a user could enter for their job, and we want to reduce that to a finite set. So where do we get that set?</p>
<p>The <a href="https://en.wikipedia.org/wiki/Occupational_Information_Network">Occupational Information Network</a> (O*NET) maintains exactly such a resource. Developed under the sponsorship of the Departoment of Labor, O*NET offers rich <a href="https://www.onetcenter.org/database.html#individual-files">datasets</a> that describe skills, knowlege, tasks, and job titles. We’re interested in the <a href="https://www.onetcenter.org/dictionary/29.3/excel/alternate_titles.html">Alternate Titles</a> file, which maps occupation titles to alternate “lay” job titles. There’s a good chance many of our users enter these alternate titles, so we’ll want to include them.</p>
<p>The file includes columns of Department of Labor and Census identifiers, but we only need the few that focus on title. Let’s download it and take a look at a few examples:</p>
<div id="cell-3" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;,&quot;height&quot;:206}}" data-outputid="6720c6eb-0d6f-47c0-f86a-a4f7982f3068" data-execution_count="1">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> numpy <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">as</span> np</span>
<span id="cb1-2"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> pandas <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">as</span> pd</span>
<span id="cb1-3"></span>
<span id="cb1-4">onet_df <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> pd.read_excel(</span>
<span id="cb1-5">    <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"https://www.onetcenter.org/dl_files/database/db_29_3_excel/Alternate%20Titles.xlsx"</span>,</span>
<span id="cb1-6">    usecols<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'Title'</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'Alternate Title'</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'Short Title'</span>],</span>
<span id="cb1-7">).fillna(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">""</span>)</span>
<span id="cb1-8"></span>
<span id="cb1-9">onet_df.sample(n<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">5</span>, random_state<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">101</span>)  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># seed for reproducibility</span></span></code></pre></div></div>
<div class="cell-output cell-output-display" data-execution_count="1">
<div id="df-758f1366-5552-42da-bea4-e668dd263999" class="colab-df-container">
    <div>


<table class="dataframe caption-top table table-sm table-striped small" data-border="1">
<thead>
<tr class="header">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">Title</th>
<th data-quarto-table-cell-role="th">Alternate Title</th>
<th data-quarto-table-cell-role="th">Short Title</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<th data-quarto-table-cell-role="th">25268</th>
<td>Cargo and Freight Agents</td>
<td>Shipping Agent</td>
<td></td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">30407</th>
<td>Helpers, Construction Trades, All Other</td>
<td>Maintenance Construction Helper</td>
<td></td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">6045</th>
<td>Bioengineers and Biomedical Engineers</td>
<td>Biomedical Engineering Intern</td>
<td></td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">18684</th>
<td>Occupational Therapy Aides</td>
<td>Rehabilitation Therapy Aide (Rehab Therapy Aide)</td>
<td>Rehab Therapy Aide</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">24086</th>
<td>Billing and Posting Clerks</td>
<td>Statement Services Representative (Statement S...</td>
<td>Statement Services Rep</td>
</tr>
</tbody>
</table>

</div>
    <div class="colab-df-buttons">

  <div class="colab-df-container">
    <button class="colab-df-convert" onclick="convertToInteractive('df-758f1366-5552-42da-bea4-e668dd263999')" title="Convert this dataframe to an interactive table." style="display:none;">

  <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 -960 960 960">
    <path d="M120-120v-720h720v720H120Zm60-500h600v-160H180v160Zm220 220h160v-160H400v160Zm0 220h160v-160H400v160ZM180-400h160v-160H180v160Zm440 0h160v-160H620v160ZM180-180h160v-160H180v160Zm440 0h160v-160H620v160Z"></path>
  </svg>
    </button>

  <style>
    .colab-df-container {
      display:flex;
      gap: 12px;
    }

    .colab-df-convert {
      background-color: #E8F0FE;
      border: none;
      border-radius: 50%;
      cursor: pointer;
      display: none;
      fill: #1967D2;
      height: 32px;
      padding: 0 0 0 0;
      width: 32px;
    }

    .colab-df-convert:hover {
      background-color: #E2EBFA;
      box-shadow: 0px 1px 2px rgba(60, 64, 67, 0.3), 0px 1px 3px 1px rgba(60, 64, 67, 0.15);
      fill: #174EA6;
    }

    .colab-df-buttons div {
      margin-bottom: 4px;
    }

    [theme=dark] .colab-df-convert {
      background-color: #3B4455;
      fill: #D2E3FC;
    }

    [theme=dark] .colab-df-convert:hover {
      background-color: #434B5C;
      box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
      filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
      fill: #FFFFFF;
    }
  </style>

    <script>
      const buttonEl =
        document.querySelector('#df-758f1366-5552-42da-bea4-e668dd263999 button.colab-df-convert');
      buttonEl.style.display =
        google.colab.kernel.accessAllowed ? 'block' : 'none';

      async function convertToInteractive(key) {
        const element = document.querySelector('#df-758f1366-5552-42da-bea4-e668dd263999');
        const dataTable =
          await google.colab.kernel.invokeFunction('convertToInteractive',
                                                    [key], {});
        if (!dataTable) return;

        const docLinkHtml = 'Like what you see? Visit the ' +
          '<a target="_blank" href=https://colab.research.google.com/notebooks/data_table.ipynb>data table notebook</a>'
          + ' to learn more about interactive tables.';
        element.innerHTML = '';
        dataTable['output_type'] = 'display_data';
        await google.colab.output.renderOutput(dataTable, element);
        const docLink = document.createElement('div');
        docLink.innerHTML = docLinkHtml;
        element.appendChild(docLink);
      }
    </script>
  </div>


    <div id="df-87a574a5-2f1b-4bb7-bec4-e23afd2b6ba0">
      <button class="colab-df-quickchart" onclick="quickchart('df-87a574a5-2f1b-4bb7-bec4-e23afd2b6ba0')" title="Suggest charts" style="display:none;">

<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 0 24 24" width="24px">
    <g>
        <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"></path>
    </g>
</svg>
      </button>

<style>
  .colab-df-quickchart {
      --bg-color: #E8F0FE;
      --fill-color: #1967D2;
      --hover-bg-color: #E2EBFA;
      --hover-fill-color: #174EA6;
      --disabled-fill-color: #AAA;
      --disabled-bg-color: #DDD;
  }

  [theme=dark] .colab-df-quickchart {
      --bg-color: #3B4455;
      --fill-color: #D2E3FC;
      --hover-bg-color: #434B5C;
      --hover-fill-color: #FFFFFF;
      --disabled-bg-color: #3B4455;
      --disabled-fill-color: #666;
  }

  .colab-df-quickchart {
    background-color: var(--bg-color);
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: none;
    fill: var(--fill-color);
    height: 32px;
    padding: 0;
    width: 32px;
  }

  .colab-df-quickchart:hover {
    background-color: var(--hover-bg-color);
    box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
    fill: var(--button-hover-fill-color);
  }

  .colab-df-quickchart-complete:disabled,
  .colab-df-quickchart-complete:disabled:hover {
    background-color: var(--disabled-bg-color);
    fill: var(--disabled-fill-color);
    box-shadow: none;
  }

  .colab-df-spinner {
    border: 2px solid var(--fill-color);
    border-color: transparent;
    border-bottom-color: var(--fill-color);
    animation:
      spin 1s steps(1) infinite;
  }

  @keyframes spin {
    0% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
      border-left-color: var(--fill-color);
    }
    20% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    30% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
      border-right-color: var(--fill-color);
    }
    40% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    60% {
      border-color: transparent;
      border-right-color: var(--fill-color);
    }
    80% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-bottom-color: var(--fill-color);
    }
    90% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
    }
  }
</style>

      <script>
        async function quickchart(key) {
          const quickchartButtonEl =
            document.querySelector('#' + key + ' button');
          quickchartButtonEl.disabled = true;  // To prevent multiple clicks.
          quickchartButtonEl.classList.add('colab-df-spinner');
          try {
            const charts = await google.colab.kernel.invokeFunction(
                'suggestCharts', [key], {});
          } catch (error) {
            console.error('Error during call to suggestCharts:', error);
          }
          quickchartButtonEl.classList.remove('colab-df-spinner');
          quickchartButtonEl.classList.add('colab-df-quickchart-complete');
        }
        (() => {
          let quickchartButtonEl =
            document.querySelector('#df-87a574a5-2f1b-4bb7-bec4-e23afd2b6ba0 button');
          quickchartButtonEl.style.display =
            google.colab.kernel.accessAllowed ? 'block' : 'none';
        })();
      </script>
    </div>

    </div>
  </div>
</div>
</div>
<p>So O*NET tells us that <strong>Cargo and Freight Agents</strong> might also go by a <strong>Shipping Agent</strong> as an <code>Alternate Title</code> and that <strong>Occupational Therapy Aides</strong> might also go by a <strong>Rehab Therapy Aide</strong> as a <code>Short Title</code>.</p>
<p>We also see that there can be many rows of different <code>Alternate Title</code> and <code>Short Title</code> for the same <code>Title</code>:</p>
<div id="cell-5" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;,&quot;height&quot;:424}}" data-outputid="76ed7de9-8150-4e52-f30c-7f12f1e70f7a" data-execution_count="2">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb2-1">onet_df[onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Software Developers"</span>]</span></code></pre></div></div>
<div class="cell-output cell-output-display" data-execution_count="2">
<div id="df-c143924f-7dcc-46ea-86ea-899d2a974128" class="colab-df-container">
    <div>


<table class="dataframe caption-top table table-sm table-striped small" data-border="1">
<thead>
<tr class="header">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">Title</th>
<th data-quarto-table-cell-role="th">Alternate Title</th>
<th data-quarto-table-cell-role="th">Short Title</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<th data-quarto-table-cell-role="th">4931</th>
<td>Software Developers</td>
<td>.NET Developer</td>
<td></td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">4932</th>
<td>Software Developers</td>
<td>Android Developer</td>
<td></td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">4933</th>
<td>Software Developers</td>
<td>AngularJS Developer</td>
<td></td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">4934</th>
<td>Software Developers</td>
<td>Apache Hadoop Developer</td>
<td></td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">4935</th>
<td>Software Developers</td>
<td>Application Architect</td>
<td></td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">...</th>
<td>...</td>
<td>...</td>
<td>...</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">5061</th>
<td>Software Developers</td>
<td>User Interface Designer</td>
<td></td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">5062</th>
<td>Software Developers</td>
<td>Video Game Engineer</td>
<td></td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">5063</th>
<td>Software Developers</td>
<td>Wide Area Network Engineer (WAN Engineer)</td>
<td>WAN Engineer</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">5064</th>
<td>Software Developers</td>
<td>Windows Software Engineer</td>
<td></td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">5065</th>
<td>Software Developers</td>
<td>XML Developer (Extensible Markup Language Deve...</td>
<td>XML Developer</td>
</tr>
</tbody>
</table>

<p>135 rows × 3 columns</p>
</div>
    <div class="colab-df-buttons">

  <div class="colab-df-container">
    <button class="colab-df-convert" onclick="convertToInteractive('df-c143924f-7dcc-46ea-86ea-899d2a974128')" title="Convert this dataframe to an interactive table." style="display:none;">

  <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 -960 960 960">
    <path d="M120-120v-720h720v720H120Zm60-500h600v-160H180v160Zm220 220h160v-160H400v160Zm0 220h160v-160H400v160ZM180-400h160v-160H180v160Zm440 0h160v-160H620v160ZM180-180h160v-160H180v160Zm440 0h160v-160H620v160Z"></path>
  </svg>
    </button>

  <style>
    .colab-df-container {
      display:flex;
      gap: 12px;
    }

    .colab-df-convert {
      background-color: #E8F0FE;
      border: none;
      border-radius: 50%;
      cursor: pointer;
      display: none;
      fill: #1967D2;
      height: 32px;
      padding: 0 0 0 0;
      width: 32px;
    }

    .colab-df-convert:hover {
      background-color: #E2EBFA;
      box-shadow: 0px 1px 2px rgba(60, 64, 67, 0.3), 0px 1px 3px 1px rgba(60, 64, 67, 0.15);
      fill: #174EA6;
    }

    .colab-df-buttons div {
      margin-bottom: 4px;
    }

    [theme=dark] .colab-df-convert {
      background-color: #3B4455;
      fill: #D2E3FC;
    }

    [theme=dark] .colab-df-convert:hover {
      background-color: #434B5C;
      box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
      filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
      fill: #FFFFFF;
    }
  </style>

    <script>
      const buttonEl =
        document.querySelector('#df-c143924f-7dcc-46ea-86ea-899d2a974128 button.colab-df-convert');
      buttonEl.style.display =
        google.colab.kernel.accessAllowed ? 'block' : 'none';

      async function convertToInteractive(key) {
        const element = document.querySelector('#df-c143924f-7dcc-46ea-86ea-899d2a974128');
        const dataTable =
          await google.colab.kernel.invokeFunction('convertToInteractive',
                                                    [key], {});
        if (!dataTable) return;

        const docLinkHtml = 'Like what you see? Visit the ' +
          '<a target="_blank" href=https://colab.research.google.com/notebooks/data_table.ipynb>data table notebook</a>'
          + ' to learn more about interactive tables.';
        element.innerHTML = '';
        dataTable['output_type'] = 'display_data';
        await google.colab.output.renderOutput(dataTable, element);
        const docLink = document.createElement('div');
        docLink.innerHTML = docLinkHtml;
        element.appendChild(docLink);
      }
    </script>
  </div>


    <div id="df-713523f4-d3ba-4823-bbd0-8720befe5f8b">
      <button class="colab-df-quickchart" onclick="quickchart('df-713523f4-d3ba-4823-bbd0-8720befe5f8b')" title="Suggest charts" style="display:none;">

<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 0 24 24" width="24px">
    <g>
        <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"></path>
    </g>
</svg>
      </button>

<style>
  .colab-df-quickchart {
      --bg-color: #E8F0FE;
      --fill-color: #1967D2;
      --hover-bg-color: #E2EBFA;
      --hover-fill-color: #174EA6;
      --disabled-fill-color: #AAA;
      --disabled-bg-color: #DDD;
  }

  [theme=dark] .colab-df-quickchart {
      --bg-color: #3B4455;
      --fill-color: #D2E3FC;
      --hover-bg-color: #434B5C;
      --hover-fill-color: #FFFFFF;
      --disabled-bg-color: #3B4455;
      --disabled-fill-color: #666;
  }

  .colab-df-quickchart {
    background-color: var(--bg-color);
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: none;
    fill: var(--fill-color);
    height: 32px;
    padding: 0;
    width: 32px;
  }

  .colab-df-quickchart:hover {
    background-color: var(--hover-bg-color);
    box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
    fill: var(--button-hover-fill-color);
  }

  .colab-df-quickchart-complete:disabled,
  .colab-df-quickchart-complete:disabled:hover {
    background-color: var(--disabled-bg-color);
    fill: var(--disabled-fill-color);
    box-shadow: none;
  }

  .colab-df-spinner {
    border: 2px solid var(--fill-color);
    border-color: transparent;
    border-bottom-color: var(--fill-color);
    animation:
      spin 1s steps(1) infinite;
  }

  @keyframes spin {
    0% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
      border-left-color: var(--fill-color);
    }
    20% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    30% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
      border-right-color: var(--fill-color);
    }
    40% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    60% {
      border-color: transparent;
      border-right-color: var(--fill-color);
    }
    80% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-bottom-color: var(--fill-color);
    }
    90% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
    }
  }
</style>

      <script>
        async function quickchart(key) {
          const quickchartButtonEl =
            document.querySelector('#' + key + ' button');
          quickchartButtonEl.disabled = true;  // To prevent multiple clicks.
          quickchartButtonEl.classList.add('colab-df-spinner');
          try {
            const charts = await google.colab.kernel.invokeFunction(
                'suggestCharts', [key], {});
          } catch (error) {
            console.error('Error during call to suggestCharts:', error);
          }
          quickchartButtonEl.classList.remove('colab-df-spinner');
          quickchartButtonEl.classList.add('colab-df-quickchart-complete');
        }
        (() => {
          let quickchartButtonEl =
            document.querySelector('#df-713523f4-d3ba-4823-bbd0-8720befe5f8b button');
          quickchartButtonEl.style.display =
            google.colab.kernel.accessAllowed ? 'block' : 'none';
        })();
      </script>
    </div>

    </div>
  </div>
</div>
</div>
<p>There is one thing we do know ahead of time about our users: not all of them will be employed. The O*NET data set doesn’t provide a job title for not working, so let’s add our own:</p>
<div id="cell-7" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;,&quot;height&quot;:238}}" data-outputid="287b983f-e0e5-4f94-da1f-250ed74b8e98" data-execution_count="3">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb3-1">additions <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> pd.DataFrame(</span>
<span id="cb3-2">    [</span>
<span id="cb3-3">        {<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Unemployed"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Alternate Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Not Employed"</span>},</span>
<span id="cb3-4">        {<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Unemployed"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Alternate Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"None"</span>},</span>
<span id="cb3-5">        {<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Unemployed"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Alternate Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"N/A"</span>},</span>
<span id="cb3-6">        {<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Unemployed"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Alternate Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"No Employment"</span>},</span>
<span id="cb3-7">        {<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Unemployed"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Alternate Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Not Working"</span>},</span>
<span id="cb3-8">        {<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Retired"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Alternate Title"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Retiree"</span>},</span>
<span id="cb3-9">    ]</span>
<span id="cb3-10">)</span>
<span id="cb3-11">onet_df <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> pd.concat([onet_df, additions], ignore_index<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>).fillna(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">""</span>)</span>
<span id="cb3-12">onet_df[(onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Unemployed"</span>) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">|</span> (onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Retired"</span>)]</span></code></pre></div></div>
<div class="cell-output cell-output-display" data-execution_count="3">
<div id="df-dd544e90-62e2-424a-b90f-7440ca90bc2e" class="colab-df-container">
    <div>


<table class="dataframe caption-top table table-sm table-striped small" data-border="1">
<thead>
<tr class="header">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">Title</th>
<th data-quarto-table-cell-role="th">Alternate Title</th>
<th data-quarto-table-cell-role="th">Short Title</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<th data-quarto-table-cell-role="th">56560</th>
<td>Unemployed</td>
<td>Not Employed</td>
<td></td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">56561</th>
<td>Unemployed</td>
<td>None</td>
<td></td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">56562</th>
<td>Unemployed</td>
<td>N/A</td>
<td></td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">56563</th>
<td>Unemployed</td>
<td>No Employment</td>
<td></td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">56564</th>
<td>Unemployed</td>
<td>Not Working</td>
<td></td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">56565</th>
<td>Retired</td>
<td>Retiree</td>
<td></td>
</tr>
</tbody>
</table>

</div>
    <div class="colab-df-buttons">

  <div class="colab-df-container">
    <button class="colab-df-convert" onclick="convertToInteractive('df-dd544e90-62e2-424a-b90f-7440ca90bc2e')" title="Convert this dataframe to an interactive table." style="display:none;">

  <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 -960 960 960">
    <path d="M120-120v-720h720v720H120Zm60-500h600v-160H180v160Zm220 220h160v-160H400v160Zm0 220h160v-160H400v160ZM180-400h160v-160H180v160Zm440 0h160v-160H620v160ZM180-180h160v-160H180v160Zm440 0h160v-160H620v160Z"></path>
  </svg>
    </button>

  <style>
    .colab-df-container {
      display:flex;
      gap: 12px;
    }

    .colab-df-convert {
      background-color: #E8F0FE;
      border: none;
      border-radius: 50%;
      cursor: pointer;
      display: none;
      fill: #1967D2;
      height: 32px;
      padding: 0 0 0 0;
      width: 32px;
    }

    .colab-df-convert:hover {
      background-color: #E2EBFA;
      box-shadow: 0px 1px 2px rgba(60, 64, 67, 0.3), 0px 1px 3px 1px rgba(60, 64, 67, 0.15);
      fill: #174EA6;
    }

    .colab-df-buttons div {
      margin-bottom: 4px;
    }

    [theme=dark] .colab-df-convert {
      background-color: #3B4455;
      fill: #D2E3FC;
    }

    [theme=dark] .colab-df-convert:hover {
      background-color: #434B5C;
      box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
      filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
      fill: #FFFFFF;
    }
  </style>

    <script>
      const buttonEl =
        document.querySelector('#df-dd544e90-62e2-424a-b90f-7440ca90bc2e button.colab-df-convert');
      buttonEl.style.display =
        google.colab.kernel.accessAllowed ? 'block' : 'none';

      async function convertToInteractive(key) {
        const element = document.querySelector('#df-dd544e90-62e2-424a-b90f-7440ca90bc2e');
        const dataTable =
          await google.colab.kernel.invokeFunction('convertToInteractive',
                                                    [key], {});
        if (!dataTable) return;

        const docLinkHtml = 'Like what you see? Visit the ' +
          '<a target="_blank" href=https://colab.research.google.com/notebooks/data_table.ipynb>data table notebook</a>'
          + ' to learn more about interactive tables.';
        element.innerHTML = '';
        dataTable['output_type'] = 'display_data';
        await google.colab.output.renderOutput(dataTable, element);
        const docLink = document.createElement('div');
        docLink.innerHTML = docLinkHtml;
        element.appendChild(docLink);
      }
    </script>
  </div>


    <div id="df-e4b43b41-c068-4beb-a8a2-96b0d403aadb">
      <button class="colab-df-quickchart" onclick="quickchart('df-e4b43b41-c068-4beb-a8a2-96b0d403aadb')" title="Suggest charts" style="display:none;">

<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 0 24 24" width="24px">
    <g>
        <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"></path>
    </g>
</svg>
      </button>

<style>
  .colab-df-quickchart {
      --bg-color: #E8F0FE;
      --fill-color: #1967D2;
      --hover-bg-color: #E2EBFA;
      --hover-fill-color: #174EA6;
      --disabled-fill-color: #AAA;
      --disabled-bg-color: #DDD;
  }

  [theme=dark] .colab-df-quickchart {
      --bg-color: #3B4455;
      --fill-color: #D2E3FC;
      --hover-bg-color: #434B5C;
      --hover-fill-color: #FFFFFF;
      --disabled-bg-color: #3B4455;
      --disabled-fill-color: #666;
  }

  .colab-df-quickchart {
    background-color: var(--bg-color);
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: none;
    fill: var(--fill-color);
    height: 32px;
    padding: 0;
    width: 32px;
  }

  .colab-df-quickchart:hover {
    background-color: var(--hover-bg-color);
    box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
    fill: var(--button-hover-fill-color);
  }

  .colab-df-quickchart-complete:disabled,
  .colab-df-quickchart-complete:disabled:hover {
    background-color: var(--disabled-bg-color);
    fill: var(--disabled-fill-color);
    box-shadow: none;
  }

  .colab-df-spinner {
    border: 2px solid var(--fill-color);
    border-color: transparent;
    border-bottom-color: var(--fill-color);
    animation:
      spin 1s steps(1) infinite;
  }

  @keyframes spin {
    0% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
      border-left-color: var(--fill-color);
    }
    20% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    30% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
      border-right-color: var(--fill-color);
    }
    40% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    60% {
      border-color: transparent;
      border-right-color: var(--fill-color);
    }
    80% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-bottom-color: var(--fill-color);
    }
    90% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
    }
  }
</style>

      <script>
        async function quickchart(key) {
          const quickchartButtonEl =
            document.querySelector('#' + key + ' button');
          quickchartButtonEl.disabled = true;  // To prevent multiple clicks.
          quickchartButtonEl.classList.add('colab-df-spinner');
          try {
            const charts = await google.colab.kernel.invokeFunction(
                'suggestCharts', [key], {});
          } catch (error) {
            console.error('Error during call to suggestCharts:', error);
          }
          quickchartButtonEl.classList.remove('colab-df-spinner');
          quickchartButtonEl.classList.add('colab-df-quickchart-complete');
        }
        (() => {
          let quickchartButtonEl =
            document.querySelector('#df-e4b43b41-c068-4beb-a8a2-96b0d403aadb button');
          quickchartButtonEl.style.display =
            google.colab.kernel.accessAllowed ? 'block' : 'none';
        })();
      </script>
    </div>

    </div>
  </div>
</div>
</div>
<p>Now let’s merge these fields together. Since we’ll be leveraging a language model, we can take the liberties of language here; we don’t need clean many-to-many relationships. Just combine <code>Title</code>, <code>Alternate Title</code>, and when available, <code>Short Title</code> into one <code>Long Title</code> field with <code>"aka"</code> inline:</p>
<div id="cell-9" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;,&quot;height&quot;:424}}" data-outputid="a6a4e52a-fde4-4659-9c64-3463b1b0a75c" data-execution_count="4">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb4" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb4-1">mask <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Short Title"</span>].eq(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">""</span>)</span>
<span id="cb4-2"></span>
<span id="cb4-3">onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Long Title"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> np.where(</span>
<span id="cb4-4">    mask,</span>
<span id="cb4-5">    onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">+</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">" aka "</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">+</span> onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Alternate Title"</span>],</span>
<span id="cb4-6">    onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">+</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">" aka "</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">+</span> onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Alternate Title"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">+</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">" aka "</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">+</span> onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Short Title"</span>],</span>
<span id="cb4-7">)</span>
<span id="cb4-8"></span>
<span id="cb4-9">onet_df[onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Software Developers"</span>]</span></code></pre></div></div>
<div class="cell-output cell-output-display" data-execution_count="4">
<div id="df-4d1d07c4-8559-4241-ac8a-7d6232c19548" class="colab-df-container">
    <div>


<table class="dataframe caption-top table table-sm table-striped small" data-border="1">
<thead>
<tr class="header">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">Title</th>
<th data-quarto-table-cell-role="th">Alternate Title</th>
<th data-quarto-table-cell-role="th">Short Title</th>
<th data-quarto-table-cell-role="th">Long Title</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<th data-quarto-table-cell-role="th">4931</th>
<td>Software Developers</td>
<td>.NET Developer</td>
<td></td>
<td>Software Developers aka .NET Developer</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">4932</th>
<td>Software Developers</td>
<td>Android Developer</td>
<td></td>
<td>Software Developers aka Android Developer</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">4933</th>
<td>Software Developers</td>
<td>AngularJS Developer</td>
<td></td>
<td>Software Developers aka AngularJS Developer</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">4934</th>
<td>Software Developers</td>
<td>Apache Hadoop Developer</td>
<td></td>
<td>Software Developers aka Apache Hadoop Developer</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">4935</th>
<td>Software Developers</td>
<td>Application Architect</td>
<td></td>
<td>Software Developers aka Application Architect</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">...</th>
<td>...</td>
<td>...</td>
<td>...</td>
<td>...</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">5061</th>
<td>Software Developers</td>
<td>User Interface Designer</td>
<td></td>
<td>Software Developers aka User Interface Designer</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">5062</th>
<td>Software Developers</td>
<td>Video Game Engineer</td>
<td></td>
<td>Software Developers aka Video Game Engineer</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">5063</th>
<td>Software Developers</td>
<td>Wide Area Network Engineer (WAN Engineer)</td>
<td>WAN Engineer</td>
<td>Software Developers aka Wide Area Network Engi...</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">5064</th>
<td>Software Developers</td>
<td>Windows Software Engineer</td>
<td></td>
<td>Software Developers aka Windows Software Engineer</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">5065</th>
<td>Software Developers</td>
<td>XML Developer (Extensible Markup Language Deve...</td>
<td>XML Developer</td>
<td>Software Developers aka XML Developer (Extensi...</td>
</tr>
</tbody>
</table>

<p>135 rows × 4 columns</p>
</div>
    <div class="colab-df-buttons">

  <div class="colab-df-container">
    <button class="colab-df-convert" onclick="convertToInteractive('df-4d1d07c4-8559-4241-ac8a-7d6232c19548')" title="Convert this dataframe to an interactive table." style="display:none;">

  <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 -960 960 960">
    <path d="M120-120v-720h720v720H120Zm60-500h600v-160H180v160Zm220 220h160v-160H400v160Zm0 220h160v-160H400v160ZM180-400h160v-160H180v160Zm440 0h160v-160H620v160ZM180-180h160v-160H180v160Zm440 0h160v-160H620v160Z"></path>
  </svg>
    </button>

  <style>
    .colab-df-container {
      display:flex;
      gap: 12px;
    }

    .colab-df-convert {
      background-color: #E8F0FE;
      border: none;
      border-radius: 50%;
      cursor: pointer;
      display: none;
      fill: #1967D2;
      height: 32px;
      padding: 0 0 0 0;
      width: 32px;
    }

    .colab-df-convert:hover {
      background-color: #E2EBFA;
      box-shadow: 0px 1px 2px rgba(60, 64, 67, 0.3), 0px 1px 3px 1px rgba(60, 64, 67, 0.15);
      fill: #174EA6;
    }

    .colab-df-buttons div {
      margin-bottom: 4px;
    }

    [theme=dark] .colab-df-convert {
      background-color: #3B4455;
      fill: #D2E3FC;
    }

    [theme=dark] .colab-df-convert:hover {
      background-color: #434B5C;
      box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
      filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
      fill: #FFFFFF;
    }
  </style>

    <script>
      const buttonEl =
        document.querySelector('#df-4d1d07c4-8559-4241-ac8a-7d6232c19548 button.colab-df-convert');
      buttonEl.style.display =
        google.colab.kernel.accessAllowed ? 'block' : 'none';

      async function convertToInteractive(key) {
        const element = document.querySelector('#df-4d1d07c4-8559-4241-ac8a-7d6232c19548');
        const dataTable =
          await google.colab.kernel.invokeFunction('convertToInteractive',
                                                    [key], {});
        if (!dataTable) return;

        const docLinkHtml = 'Like what you see? Visit the ' +
          '<a target="_blank" href=https://colab.research.google.com/notebooks/data_table.ipynb>data table notebook</a>'
          + ' to learn more about interactive tables.';
        element.innerHTML = '';
        dataTable['output_type'] = 'display_data';
        await google.colab.output.renderOutput(dataTable, element);
        const docLink = document.createElement('div');
        docLink.innerHTML = docLinkHtml;
        element.appendChild(docLink);
      }
    </script>
  </div>


    <div id="df-cefd45d0-c35d-43ee-9e17-d6355a7128d7">
      <button class="colab-df-quickchart" onclick="quickchart('df-cefd45d0-c35d-43ee-9e17-d6355a7128d7')" title="Suggest charts" style="display:none;">

<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 0 24 24" width="24px">
    <g>
        <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"></path>
    </g>
</svg>
      </button>

<style>
  .colab-df-quickchart {
      --bg-color: #E8F0FE;
      --fill-color: #1967D2;
      --hover-bg-color: #E2EBFA;
      --hover-fill-color: #174EA6;
      --disabled-fill-color: #AAA;
      --disabled-bg-color: #DDD;
  }

  [theme=dark] .colab-df-quickchart {
      --bg-color: #3B4455;
      --fill-color: #D2E3FC;
      --hover-bg-color: #434B5C;
      --hover-fill-color: #FFFFFF;
      --disabled-bg-color: #3B4455;
      --disabled-fill-color: #666;
  }

  .colab-df-quickchart {
    background-color: var(--bg-color);
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: none;
    fill: var(--fill-color);
    height: 32px;
    padding: 0;
    width: 32px;
  }

  .colab-df-quickchart:hover {
    background-color: var(--hover-bg-color);
    box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
    fill: var(--button-hover-fill-color);
  }

  .colab-df-quickchart-complete:disabled,
  .colab-df-quickchart-complete:disabled:hover {
    background-color: var(--disabled-bg-color);
    fill: var(--disabled-fill-color);
    box-shadow: none;
  }

  .colab-df-spinner {
    border: 2px solid var(--fill-color);
    border-color: transparent;
    border-bottom-color: var(--fill-color);
    animation:
      spin 1s steps(1) infinite;
  }

  @keyframes spin {
    0% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
      border-left-color: var(--fill-color);
    }
    20% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    30% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
      border-right-color: var(--fill-color);
    }
    40% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    60% {
      border-color: transparent;
      border-right-color: var(--fill-color);
    }
    80% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-bottom-color: var(--fill-color);
    }
    90% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
    }
  }
</style>

      <script>
        async function quickchart(key) {
          const quickchartButtonEl =
            document.querySelector('#' + key + ' button');
          quickchartButtonEl.disabled = true;  // To prevent multiple clicks.
          quickchartButtonEl.classList.add('colab-df-spinner');
          try {
            const charts = await google.colab.kernel.invokeFunction(
                'suggestCharts', [key], {});
          } catch (error) {
            console.error('Error during call to suggestCharts:', error);
          }
          quickchartButtonEl.classList.remove('colab-df-spinner');
          quickchartButtonEl.classList.add('colab-df-quickchart-complete');
        }
        (() => {
          let quickchartButtonEl =
            document.querySelector('#df-cefd45d0-c35d-43ee-9e17-d6355a7128d7 button');
          quickchartButtonEl.style.display =
            google.colab.kernel.accessAllowed ? 'block' : 'none';
        })();
      </script>
    </div>

    </div>
  </div>
</div>
</div>
<p>Great. We’ve satisfied parts one and two of our approach. We have a finite set of job titles, and we have a good understanding that the set is large but not exhaustive, and it combines multiple values for a job title. Let’s start modeling language.</p>
<p><a href="https://huggingface.co/TechWolf/JobBERT-v2">JobBERT-v2</a> is a <a href="https://www.sbert.net/">sentence-transformers</a> model fine tuned from <a href="https://huggingface.co/sentence-transformers/all-mpnet-base-v2">all-mpnet-base-v2</a> specifically for job title matching and similarity. Hey that’s convenient!</p>
<p>We can’t use <code>JobBERT</code> out of the box, we’ll need to incorporate our O*NET dataset. Let’s pull it down and start building out our implementation of the model. To do this we’re going to leverage <a href="https://en.wikipedia.org/wiki/Word_embedding">word embeddings</a> against our <code>Long Title</code> values. If you’re unfamiliar with langage model embeddings, Simon Willison has a <a href="https://simonwillison.net/2023/Oct/23/embeddings/">fantastic overview</a> that you should go read now. But the gist of it is: embeddings are how language models numerically encode meaning from language into a large vector. This is suprisingly powerful, and yields operations like:</p>
<p><code>emb('king') - emb('man') + emb('woman')</code> which returns a vector that is mathmatically <em>very close</em> to <code>emb('queen')</code>.</p>
<p>We’re going to use this “closeness” between vectors to reduce infinite free-form data to our finite <code>Long Title</code> data and then map it back to <code>Title</code>. The first thing to do is quite simple: calculate <code>JobBERT</code> embeddings on all of the values in our <code>Long Title</code> column:</p>
<div id="cell-11" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;,&quot;height&quot;:206}}" data-outputid="c0689d5c-e02e-42d6-ead0-cb0730e7f47f" data-execution_count="14">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb5" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb5-1"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">from</span> sentence_transformers <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> SentenceTransformer</span>
<span id="cb5-2"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> warnings</span>
<span id="cb5-3">warnings.filterwarnings(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"ignore"</span>)</span>
<span id="cb5-4"></span>
<span id="cb5-5">model <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> SentenceTransformer(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"TechWolf/JobBERT-v2"</span>)</span>
<span id="cb5-6"></span>
<span id="cb5-7">onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"embedding"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">list</span>(</span>
<span id="cb5-8">    model.encode(</span>
<span id="cb5-9">        onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Long Title"</span>].tolist(),</span>
<span id="cb5-10">        normalize_embeddings<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>,</span>
<span id="cb5-11">        convert_to_numpy<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>,</span>
<span id="cb5-12">        show_progress_bar<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">False</span>,</span>
<span id="cb5-13">    )</span>
<span id="cb5-14">)</span>
<span id="cb5-15"></span>
<span id="cb5-16">onet_df[[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Long Title"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"embedding"</span>]].sample(n<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">5</span>, random_state<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">101</span>)</span></code></pre></div></div>
<div class="cell-output cell-output-display" data-execution_count="14">
<div id="df-50722b38-d2db-4afa-ac86-bdffe3ec87a5" class="colab-df-container">
    <div>


<table class="dataframe caption-top table table-sm table-striped small" data-border="1">
<thead>
<tr class="header">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">Long Title</th>
<th data-quarto-table-cell-role="th">embedding</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<th data-quarto-table-cell-role="th">25268</th>
<td>Cargo and Freight Agents aka Shipping Agent</td>
<td>[-0.034480397, -0.01120864, -0.005822623, -0.0...</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">30408</th>
<td>Helpers, Construction Trades, All Other aka Me...</td>
<td>[-0.07022176, -0.020068161, 0.0111531215, -0.0...</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">6045</th>
<td>Bioengineers and Biomedical Engineers aka Biom...</td>
<td>[-0.030613927, -0.059320696, -0.01718829, -0.0...</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">18684</th>
<td>Occupational Therapy Aides aka Rehabilitation ...</td>
<td>[-0.014956265, -0.038792193, -0.00349255, 0.00...</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">24086</th>
<td>Billing and Posting Clerks aka Statement Servi...</td>
<td>[0.020981414, -0.033710796, 0.03225505, -0.011...</td>
</tr>
</tbody>
</table>

</div>
    <div class="colab-df-buttons">

  <div class="colab-df-container">
    <button class="colab-df-convert" onclick="convertToInteractive('df-50722b38-d2db-4afa-ac86-bdffe3ec87a5')" title="Convert this dataframe to an interactive table." style="display:none;">

  <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 -960 960 960">
    <path d="M120-120v-720h720v720H120Zm60-500h600v-160H180v160Zm220 220h160v-160H400v160Zm0 220h160v-160H400v160ZM180-400h160v-160H180v160Zm440 0h160v-160H620v160ZM180-180h160v-160H180v160Zm440 0h160v-160H620v160Z"></path>
  </svg>
    </button>

  <style>
    .colab-df-container {
      display:flex;
      gap: 12px;
    }

    .colab-df-convert {
      background-color: #E8F0FE;
      border: none;
      border-radius: 50%;
      cursor: pointer;
      display: none;
      fill: #1967D2;
      height: 32px;
      padding: 0 0 0 0;
      width: 32px;
    }

    .colab-df-convert:hover {
      background-color: #E2EBFA;
      box-shadow: 0px 1px 2px rgba(60, 64, 67, 0.3), 0px 1px 3px 1px rgba(60, 64, 67, 0.15);
      fill: #174EA6;
    }

    .colab-df-buttons div {
      margin-bottom: 4px;
    }

    [theme=dark] .colab-df-convert {
      background-color: #3B4455;
      fill: #D2E3FC;
    }

    [theme=dark] .colab-df-convert:hover {
      background-color: #434B5C;
      box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
      filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
      fill: #FFFFFF;
    }
  </style>

    <script>
      const buttonEl =
        document.querySelector('#df-50722b38-d2db-4afa-ac86-bdffe3ec87a5 button.colab-df-convert');
      buttonEl.style.display =
        google.colab.kernel.accessAllowed ? 'block' : 'none';

      async function convertToInteractive(key) {
        const element = document.querySelector('#df-50722b38-d2db-4afa-ac86-bdffe3ec87a5');
        const dataTable =
          await google.colab.kernel.invokeFunction('convertToInteractive',
                                                    [key], {});
        if (!dataTable) return;

        const docLinkHtml = 'Like what you see? Visit the ' +
          '<a target="_blank" href=https://colab.research.google.com/notebooks/data_table.ipynb>data table notebook</a>'
          + ' to learn more about interactive tables.';
        element.innerHTML = '';
        dataTable['output_type'] = 'display_data';
        await google.colab.output.renderOutput(dataTable, element);
        const docLink = document.createElement('div');
        docLink.innerHTML = docLinkHtml;
        element.appendChild(docLink);
      }
    </script>
  </div>


    <div id="df-124c9445-d054-4c56-8465-c4852571c4ad">
      <button class="colab-df-quickchart" onclick="quickchart('df-124c9445-d054-4c56-8465-c4852571c4ad')" title="Suggest charts" style="display:none;">

<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 0 24 24" width="24px">
    <g>
        <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"></path>
    </g>
</svg>
      </button>

<style>
  .colab-df-quickchart {
      --bg-color: #E8F0FE;
      --fill-color: #1967D2;
      --hover-bg-color: #E2EBFA;
      --hover-fill-color: #174EA6;
      --disabled-fill-color: #AAA;
      --disabled-bg-color: #DDD;
  }

  [theme=dark] .colab-df-quickchart {
      --bg-color: #3B4455;
      --fill-color: #D2E3FC;
      --hover-bg-color: #434B5C;
      --hover-fill-color: #FFFFFF;
      --disabled-bg-color: #3B4455;
      --disabled-fill-color: #666;
  }

  .colab-df-quickchart {
    background-color: var(--bg-color);
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: none;
    fill: var(--fill-color);
    height: 32px;
    padding: 0;
    width: 32px;
  }

  .colab-df-quickchart:hover {
    background-color: var(--hover-bg-color);
    box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
    fill: var(--button-hover-fill-color);
  }

  .colab-df-quickchart-complete:disabled,
  .colab-df-quickchart-complete:disabled:hover {
    background-color: var(--disabled-bg-color);
    fill: var(--disabled-fill-color);
    box-shadow: none;
  }

  .colab-df-spinner {
    border: 2px solid var(--fill-color);
    border-color: transparent;
    border-bottom-color: var(--fill-color);
    animation:
      spin 1s steps(1) infinite;
  }

  @keyframes spin {
    0% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
      border-left-color: var(--fill-color);
    }
    20% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    30% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
      border-right-color: var(--fill-color);
    }
    40% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    60% {
      border-color: transparent;
      border-right-color: var(--fill-color);
    }
    80% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-bottom-color: var(--fill-color);
    }
    90% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
    }
  }
</style>

      <script>
        async function quickchart(key) {
          const quickchartButtonEl =
            document.querySelector('#' + key + ' button');
          quickchartButtonEl.disabled = true;  // To prevent multiple clicks.
          quickchartButtonEl.classList.add('colab-df-spinner');
          try {
            const charts = await google.colab.kernel.invokeFunction(
                'suggestCharts', [key], {});
          } catch (error) {
            console.error('Error during call to suggestCharts:', error);
          }
          quickchartButtonEl.classList.remove('colab-df-spinner');
          quickchartButtonEl.classList.add('colab-df-quickchart-complete');
        }
        (() => {
          let quickchartButtonEl =
            document.querySelector('#df-124c9445-d054-4c56-8465-c4852571c4ad button');
          quickchartButtonEl.style.display =
            google.colab.kernel.accessAllowed ? 'block' : 'none';
        })();
      </script>
    </div>

    </div>
  </div>
</div>
</div>
<p>As a reader, the <code>embedding</code> column is an indecipherable array of floats, but now we can do some cool things. Here are three rows from our data:</p>
<div id="cell-13" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;,&quot;height&quot;:143}}" data-outputid="6691628c-10d7-456a-bf1f-ce3da300422a" data-execution_count="6">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb6" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb6-1"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">slice</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> onet_df[</span>
<span id="cb6-2">    (onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Long Title"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Software Developers aka Video Game Engineer"</span>) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">|</span></span>
<span id="cb6-3">    (onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Long Title"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Database Architects aka Information Architect"</span>) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">|</span></span>
<span id="cb6-4">    (onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Long Title"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Cargo and Freight Agents aka Shipping Agent"</span>)</span>
<span id="cb6-5">]</span>
<span id="cb6-6"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">slice</span>[[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Long Title"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"embedding"</span>]]</span></code></pre></div></div>
<div class="cell-output cell-output-display" data-execution_count="6">
<div id="df-8d8fb5de-fec1-447f-bcc5-1f0a6c1f6859" class="colab-df-container">
    <div>


<table class="dataframe caption-top table table-sm table-striped small" data-border="1">
<thead>
<tr class="header">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">Long Title</th>
<th data-quarto-table-cell-role="th">embedding</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<th data-quarto-table-cell-role="th">4806</th>
<td>Database Architects aka Information Architect</td>
<td>[0.020110216, 0.06301323, -0.029263753, -0.022...</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">5062</th>
<td>Software Developers aka Video Game Engineer</td>
<td>[0.041696787, 0.024444718, -0.053837907, 0.031...</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">25268</th>
<td>Cargo and Freight Agents aka Shipping Agent</td>
<td>[-0.034480397, -0.01120864, -0.005822623, -0.0...</td>
</tr>
</tbody>
</table>

</div>
    <div class="colab-df-buttons">

  <div class="colab-df-container">
    <button class="colab-df-convert" onclick="convertToInteractive('df-8d8fb5de-fec1-447f-bcc5-1f0a6c1f6859')" title="Convert this dataframe to an interactive table." style="display:none;">

  <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 -960 960 960">
    <path d="M120-120v-720h720v720H120Zm60-500h600v-160H180v160Zm220 220h160v-160H400v160Zm0 220h160v-160H400v160ZM180-400h160v-160H180v160Zm440 0h160v-160H620v160ZM180-180h160v-160H180v160Zm440 0h160v-160H620v160Z"></path>
  </svg>
    </button>

  <style>
    .colab-df-container {
      display:flex;
      gap: 12px;
    }

    .colab-df-convert {
      background-color: #E8F0FE;
      border: none;
      border-radius: 50%;
      cursor: pointer;
      display: none;
      fill: #1967D2;
      height: 32px;
      padding: 0 0 0 0;
      width: 32px;
    }

    .colab-df-convert:hover {
      background-color: #E2EBFA;
      box-shadow: 0px 1px 2px rgba(60, 64, 67, 0.3), 0px 1px 3px 1px rgba(60, 64, 67, 0.15);
      fill: #174EA6;
    }

    .colab-df-buttons div {
      margin-bottom: 4px;
    }

    [theme=dark] .colab-df-convert {
      background-color: #3B4455;
      fill: #D2E3FC;
    }

    [theme=dark] .colab-df-convert:hover {
      background-color: #434B5C;
      box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
      filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
      fill: #FFFFFF;
    }
  </style>

    <script>
      const buttonEl =
        document.querySelector('#df-8d8fb5de-fec1-447f-bcc5-1f0a6c1f6859 button.colab-df-convert');
      buttonEl.style.display =
        google.colab.kernel.accessAllowed ? 'block' : 'none';

      async function convertToInteractive(key) {
        const element = document.querySelector('#df-8d8fb5de-fec1-447f-bcc5-1f0a6c1f6859');
        const dataTable =
          await google.colab.kernel.invokeFunction('convertToInteractive',
                                                    [key], {});
        if (!dataTable) return;

        const docLinkHtml = 'Like what you see? Visit the ' +
          '<a target="_blank" href=https://colab.research.google.com/notebooks/data_table.ipynb>data table notebook</a>'
          + ' to learn more about interactive tables.';
        element.innerHTML = '';
        dataTable['output_type'] = 'display_data';
        await google.colab.output.renderOutput(dataTable, element);
        const docLink = document.createElement('div');
        docLink.innerHTML = docLinkHtml;
        element.appendChild(docLink);
      }
    </script>
  </div>


    <div id="df-f885962c-3cc4-4c92-af03-69da27954f82">
      <button class="colab-df-quickchart" onclick="quickchart('df-f885962c-3cc4-4c92-af03-69da27954f82')" title="Suggest charts" style="display:none;">

<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 0 24 24" width="24px">
    <g>
        <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"></path>
    </g>
</svg>
      </button>

<style>
  .colab-df-quickchart {
      --bg-color: #E8F0FE;
      --fill-color: #1967D2;
      --hover-bg-color: #E2EBFA;
      --hover-fill-color: #174EA6;
      --disabled-fill-color: #AAA;
      --disabled-bg-color: #DDD;
  }

  [theme=dark] .colab-df-quickchart {
      --bg-color: #3B4455;
      --fill-color: #D2E3FC;
      --hover-bg-color: #434B5C;
      --hover-fill-color: #FFFFFF;
      --disabled-bg-color: #3B4455;
      --disabled-fill-color: #666;
  }

  .colab-df-quickchart {
    background-color: var(--bg-color);
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: none;
    fill: var(--fill-color);
    height: 32px;
    padding: 0;
    width: 32px;
  }

  .colab-df-quickchart:hover {
    background-color: var(--hover-bg-color);
    box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
    fill: var(--button-hover-fill-color);
  }

  .colab-df-quickchart-complete:disabled,
  .colab-df-quickchart-complete:disabled:hover {
    background-color: var(--disabled-bg-color);
    fill: var(--disabled-fill-color);
    box-shadow: none;
  }

  .colab-df-spinner {
    border: 2px solid var(--fill-color);
    border-color: transparent;
    border-bottom-color: var(--fill-color);
    animation:
      spin 1s steps(1) infinite;
  }

  @keyframes spin {
    0% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
      border-left-color: var(--fill-color);
    }
    20% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    30% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
      border-right-color: var(--fill-color);
    }
    40% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    60% {
      border-color: transparent;
      border-right-color: var(--fill-color);
    }
    80% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-bottom-color: var(--fill-color);
    }
    90% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
    }
  }
</style>

      <script>
        async function quickchart(key) {
          const quickchartButtonEl =
            document.querySelector('#' + key + ' button');
          quickchartButtonEl.disabled = true;  // To prevent multiple clicks.
          quickchartButtonEl.classList.add('colab-df-spinner');
          try {
            const charts = await google.colab.kernel.invokeFunction(
                'suggestCharts', [key], {});
          } catch (error) {
            console.error('Error during call to suggestCharts:', error);
          }
          quickchartButtonEl.classList.remove('colab-df-spinner');
          quickchartButtonEl.classList.add('colab-df-quickchart-complete');
        }
        (() => {
          let quickchartButtonEl =
            document.querySelector('#df-f885962c-3cc4-4c92-af03-69da27954f82 button');
          quickchartButtonEl.style.display =
            google.colab.kernel.accessAllowed ? 'block' : 'none';
        })();
      </script>
    </div>

    </div>
  </div>
</div>
</div>
<p>In a vector space, you can evaluate how similar two vectors are by taking their <a href="https://en.wikipedia.org/wiki/Cosine_similarity">cosine similarity</a>. Since we normalized our vectors when we embedded them, the denominator in the cosine function becomes <code>1</code> so we can do this even more efficiently with just a <a href="https://en.wikipedia.org/wiki/Dot_product">dot product</a>:</p>
<div id="cell-15" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;}}" data-outputid="d670cf17-fa52-40f7-b090-ea1fc384ef71" data-execution_count="7">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb7" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb7-1">database_architect <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">slice</span>.iloc[<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>][<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"embedding"</span>]</span>
<span id="cb7-2">software_developer <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">slice</span>.iloc[<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>][<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"embedding"</span>]</span>
<span id="cb7-3">cargo_and_freight_agent <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">slice</span>.iloc[<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span>][<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"embedding"</span>]</span>
<span id="cb7-4"></span>
<span id="cb7-5"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(<span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">f"Software Developer vs Data Architect: </span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">{</span>software_developer <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">@</span> database_architect<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">}</span><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span>)</span>
<span id="cb7-6"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(<span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">f"Software Developer vs Cargo Agent: </span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">{</span>software_developer <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">@</span> cargo_and_freight_agent<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">}</span><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span>)</span></code></pre></div></div>
<div class="cell-output cell-output-stdout">
<pre><code>Software Developer vs Data Architect: 0.2380954772233963
Software Developer vs Cargo Agent: 0.08600345253944397</code></pre>
</div>
</div>
<p>Those numbers look perfectly reasonable: a modest overlap (≈ 0.24) between two tech roles and an almost-orthogonal relationship (≈ 0.09) to the Cargo Agent job.</p>
<p>Great. So now we have a mathematical way to compare the language of two job titles. And we’re not touching chatbots or 3rd party APIs at inferrence. The DataFrame is a self-contained model for semantically matching across the O*NET dataset. We’ve fully satisfied our approach!</p>
<p>Now to apply it to our problem. Instead of evaluating O*NET data against itself, we can use our embeddings to evaluate <em>any</em> free-form job title text a user might submit.</p>
<p>Let’s go get some real data to try it out! Since I work in political tech, I like to reach for campaign donor data.</p>
<p>Our friends over at ProPublica <a href="https://projects.propublica.org/itemizer/committee/C00401224/2024/filings/1791562/downloads">publish itemized ActBlue receipts by state</a>. Because ActBlue is a conduit committee, these files include every transaction of any amount. That’s a lot of transactions! Let’s grab all of the ActBlue transactions from Texas for June 2024.</p>
<div id="cell-18" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;,&quot;height&quot;:363}}" data-outputid="61f7692b-5f8f-4a72-e0b4-4b8172b67e51" data-execution_count="8">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb9" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb9-1">dtypes <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> {</span>
<span id="cb9-2">    <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"flag_orgind"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"string"</span>,</span>
<span id="cb9-3">    <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"first_name"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"string"</span>,</span>
<span id="cb9-4">    <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"city"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"string"</span>,</span>
<span id="cb9-5">    <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"zip"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"string"</span>,</span>
<span id="cb9-6">    <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"amount"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"float64"</span>,</span>
<span id="cb9-7">    <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"occupation"</span>: <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"string"</span>,</span>
<span id="cb9-8">}</span>
<span id="cb9-9"></span>
<span id="cb9-10">abtx_df <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> pd.read_csv(</span>
<span id="cb9-11">    <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"https://pp-projects-static.s3.amazonaws.com/itemizer/sa_1791562_tx.csv"</span>,</span>
<span id="cb9-12">    usecols<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>dtypes.keys(),</span>
<span id="cb9-13">    dtype<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>dtypes,</span>
<span id="cb9-14">)</span>
<span id="cb9-15">abtx_df <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> abtx_df[abtx_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"flag_orgind"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"IND"</span>]</span>
<span id="cb9-16">abtx_df.drop(columns<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"flag_orgind"</span>], inplace<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>)</span>
<span id="cb9-17">abtx_df.dropna(inplace<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>)</span>
<span id="cb9-18"></span>
<span id="cb9-19">employed <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">lambda</span> df: df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"occupation"</span>].ne(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"NOT EMPLOYED"</span>)</span>
<span id="cb9-20">get_sample <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">lambda</span> seed: abtx_df.loc[employed(abtx_df)].sample(n<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">10</span>, random_state<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>seed)</span>
<span id="cb9-21"></span>
<span id="cb9-22">get_sample(<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">30330</span>)  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># seed for reproducibility</span></span></code></pre></div></div>
<div class="cell-output cell-output-display" data-execution_count="8">
<div id="df-28f43e9b-d7fb-436a-874a-f7d5dab9fc8a" class="colab-df-container">
    <div>


<table class="dataframe caption-top table table-sm table-striped small" data-border="1">
<thead>
<tr class="header">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">first_name</th>
<th data-quarto-table-cell-role="th">city</th>
<th data-quarto-table-cell-role="th">zip</th>
<th data-quarto-table-cell-role="th">amount</th>
<th data-quarto-table-cell-role="th">occupation</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<th data-quarto-table-cell-role="th">139133</th>
<td>PAT</td>
<td>NORTH RICHLAND HIL</td>
<td>76180</td>
<td>9.0</td>
<td>RETIRED</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">4248</th>
<td>HARRY</td>
<td>HOUSTON</td>
<td>77019</td>
<td>125.0</td>
<td>LAWYER</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">221073</th>
<td>THAO</td>
<td>HOUSTON</td>
<td>77083</td>
<td>2.5</td>
<td>HISTOLOGY TECHNICIAN</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">201669</th>
<td>PHILIP</td>
<td>SAN ANTONIO</td>
<td>78240</td>
<td>3.0</td>
<td>MANAGER</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">256487</th>
<td>MARIA</td>
<td>HOLLAND</td>
<td>76534</td>
<td>1.0</td>
<td>HEALTHCARE ADMINISTRATOR</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">6330</th>
<td>ELLEN</td>
<td>BELLAIRE</td>
<td>77401</td>
<td>100.0</td>
<td>ARBITRATOR</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">125976</th>
<td>MICHELLE</td>
<td>FORT WORTH</td>
<td>76133</td>
<td>10.0</td>
<td>SERVER</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">13659</th>
<td>ROSE</td>
<td>HOUSTON</td>
<td>77024</td>
<td>75.0</td>
<td>NURSING</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">138213</th>
<td>PETER</td>
<td>HOUSTON</td>
<td>77019</td>
<td>9.0</td>
<td>DATABASE ANALYST</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">62939</th>
<td>ERIN</td>
<td>FRISCO</td>
<td>75035</td>
<td>25.0</td>
<td>NONPROFIT</td>
</tr>
</tbody>
</table>

</div>
    <div class="colab-df-buttons">

  <div class="colab-df-container">
    <button class="colab-df-convert" onclick="convertToInteractive('df-28f43e9b-d7fb-436a-874a-f7d5dab9fc8a')" title="Convert this dataframe to an interactive table." style="display:none;">

  <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 -960 960 960">
    <path d="M120-120v-720h720v720H120Zm60-500h600v-160H180v160Zm220 220h160v-160H400v160Zm0 220h160v-160H400v160ZM180-400h160v-160H180v160Zm440 0h160v-160H620v160ZM180-180h160v-160H180v160Zm440 0h160v-160H620v160Z"></path>
  </svg>
    </button>

  <style>
    .colab-df-container {
      display:flex;
      gap: 12px;
    }

    .colab-df-convert {
      background-color: #E8F0FE;
      border: none;
      border-radius: 50%;
      cursor: pointer;
      display: none;
      fill: #1967D2;
      height: 32px;
      padding: 0 0 0 0;
      width: 32px;
    }

    .colab-df-convert:hover {
      background-color: #E2EBFA;
      box-shadow: 0px 1px 2px rgba(60, 64, 67, 0.3), 0px 1px 3px 1px rgba(60, 64, 67, 0.15);
      fill: #174EA6;
    }

    .colab-df-buttons div {
      margin-bottom: 4px;
    }

    [theme=dark] .colab-df-convert {
      background-color: #3B4455;
      fill: #D2E3FC;
    }

    [theme=dark] .colab-df-convert:hover {
      background-color: #434B5C;
      box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
      filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
      fill: #FFFFFF;
    }
  </style>

    <script>
      const buttonEl =
        document.querySelector('#df-28f43e9b-d7fb-436a-874a-f7d5dab9fc8a button.colab-df-convert');
      buttonEl.style.display =
        google.colab.kernel.accessAllowed ? 'block' : 'none';

      async function convertToInteractive(key) {
        const element = document.querySelector('#df-28f43e9b-d7fb-436a-874a-f7d5dab9fc8a');
        const dataTable =
          await google.colab.kernel.invokeFunction('convertToInteractive',
                                                    [key], {});
        if (!dataTable) return;

        const docLinkHtml = 'Like what you see? Visit the ' +
          '<a target="_blank" href=https://colab.research.google.com/notebooks/data_table.ipynb>data table notebook</a>'
          + ' to learn more about interactive tables.';
        element.innerHTML = '';
        dataTable['output_type'] = 'display_data';
        await google.colab.output.renderOutput(dataTable, element);
        const docLink = document.createElement('div');
        docLink.innerHTML = docLinkHtml;
        element.appendChild(docLink);
      }
    </script>
  </div>


    <div id="df-68bf1acc-580a-453a-afa7-1de23da4efbf">
      <button class="colab-df-quickchart" onclick="quickchart('df-68bf1acc-580a-453a-afa7-1de23da4efbf')" title="Suggest charts" style="display:none;">

<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 0 24 24" width="24px">
    <g>
        <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"></path>
    </g>
</svg>
      </button>

<style>
  .colab-df-quickchart {
      --bg-color: #E8F0FE;
      --fill-color: #1967D2;
      --hover-bg-color: #E2EBFA;
      --hover-fill-color: #174EA6;
      --disabled-fill-color: #AAA;
      --disabled-bg-color: #DDD;
  }

  [theme=dark] .colab-df-quickchart {
      --bg-color: #3B4455;
      --fill-color: #D2E3FC;
      --hover-bg-color: #434B5C;
      --hover-fill-color: #FFFFFF;
      --disabled-bg-color: #3B4455;
      --disabled-fill-color: #666;
  }

  .colab-df-quickchart {
    background-color: var(--bg-color);
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: none;
    fill: var(--fill-color);
    height: 32px;
    padding: 0;
    width: 32px;
  }

  .colab-df-quickchart:hover {
    background-color: var(--hover-bg-color);
    box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
    fill: var(--button-hover-fill-color);
  }

  .colab-df-quickchart-complete:disabled,
  .colab-df-quickchart-complete:disabled:hover {
    background-color: var(--disabled-bg-color);
    fill: var(--disabled-fill-color);
    box-shadow: none;
  }

  .colab-df-spinner {
    border: 2px solid var(--fill-color);
    border-color: transparent;
    border-bottom-color: var(--fill-color);
    animation:
      spin 1s steps(1) infinite;
  }

  @keyframes spin {
    0% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
      border-left-color: var(--fill-color);
    }
    20% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    30% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
      border-right-color: var(--fill-color);
    }
    40% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    60% {
      border-color: transparent;
      border-right-color: var(--fill-color);
    }
    80% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-bottom-color: var(--fill-color);
    }
    90% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
    }
  }
</style>

      <script>
        async function quickchart(key) {
          const quickchartButtonEl =
            document.querySelector('#' + key + ' button');
          quickchartButtonEl.disabled = true;  // To prevent multiple clicks.
          quickchartButtonEl.classList.add('colab-df-spinner');
          try {
            const charts = await google.colab.kernel.invokeFunction(
                'suggestCharts', [key], {});
          } catch (error) {
            console.error('Error during call to suggestCharts:', error);
          }
          quickchartButtonEl.classList.remove('colab-df-spinner');
          quickchartButtonEl.classList.add('colab-df-quickchart-complete');
        }
        (() => {
          let quickchartButtonEl =
            document.querySelector('#df-68bf1acc-580a-453a-afa7-1de23da4efbf button');
          quickchartButtonEl.style.display =
            google.colab.kernel.accessAllowed ? 'block' : 'none';
        })();
      </script>
    </div>

    </div>
  </div>
</div>
</div>
<p>That <code>occupation</code> field came from donors and doesn’t perfectly match our modeled job titles. But we don’t need it to! Let’s use our model to calculate embeddings on these new values:</p>
<div id="cell-20" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;,&quot;height&quot;:363}}" data-outputid="fdc95ad3-bb0a-486e-b7fe-d142a580ae34" data-execution_count="9">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb10" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb10-1">abtx_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"embedding"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">list</span>(</span>
<span id="cb10-2">    model.encode(</span>
<span id="cb10-3">        abtx_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"occupation"</span>].tolist(),</span>
<span id="cb10-4">        normalize_embeddings<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>,</span>
<span id="cb10-5">        convert_to_numpy<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>,</span>
<span id="cb10-6">        show_progress_bar<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">False</span>,</span>
<span id="cb10-7">    )</span>
<span id="cb10-8">)</span>
<span id="cb10-9"></span>
<span id="cb10-10">get_sample(<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">30330</span>)</span></code></pre></div></div>
<div class="cell-output cell-output-display" data-execution_count="9">
<div id="df-4ec6b342-a3ca-4e2e-9f4e-abee488afd11" class="colab-df-container">
    <div>


<table class="dataframe caption-top table table-sm table-striped small" data-border="1">
<thead>
<tr class="header">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">first_name</th>
<th data-quarto-table-cell-role="th">city</th>
<th data-quarto-table-cell-role="th">zip</th>
<th data-quarto-table-cell-role="th">amount</th>
<th data-quarto-table-cell-role="th">occupation</th>
<th data-quarto-table-cell-role="th">embedding</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<th data-quarto-table-cell-role="th">139133</th>
<td>PAT</td>
<td>NORTH RICHLAND HIL</td>
<td>76180</td>
<td>9.0</td>
<td>RETIRED</td>
<td>[-0.022304475, 0.08798518, 0.008374137, 0.0150...</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">4248</th>
<td>HARRY</td>
<td>HOUSTON</td>
<td>77019</td>
<td>125.0</td>
<td>LAWYER</td>
<td>[0.0123939905, 0.06054912, 0.0046267705, -0.03...</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">221073</th>
<td>THAO</td>
<td>HOUSTON</td>
<td>77083</td>
<td>2.5</td>
<td>HISTOLOGY TECHNICIAN</td>
<td>[0.027493875, -0.079993084, 0.013278877, -0.00...</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">201669</th>
<td>PHILIP</td>
<td>SAN ANTONIO</td>
<td>78240</td>
<td>3.0</td>
<td>MANAGER</td>
<td>[0.09953636, 0.07623968, 0.020005615, 0.001361...</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">256487</th>
<td>MARIA</td>
<td>HOLLAND</td>
<td>76534</td>
<td>1.0</td>
<td>HEALTHCARE ADMINISTRATOR</td>
<td>[0.036386397, 0.06352263, -0.0023324555, -0.02...</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">6330</th>
<td>ELLEN</td>
<td>BELLAIRE</td>
<td>77401</td>
<td>100.0</td>
<td>ARBITRATOR</td>
<td>[0.05744064, 0.03044543, -0.011071598, 0.01120...</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">125976</th>
<td>MICHELLE</td>
<td>FORT WORTH</td>
<td>76133</td>
<td>10.0</td>
<td>SERVER</td>
<td>[0.0222358, 0.025343752, -0.027377797, -0.0031...</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">13659</th>
<td>ROSE</td>
<td>HOUSTON</td>
<td>77024</td>
<td>75.0</td>
<td>NURSING</td>
<td>[0.008113306, 0.03859657, -0.014793488, -0.064...</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">138213</th>
<td>PETER</td>
<td>HOUSTON</td>
<td>77019</td>
<td>9.0</td>
<td>DATABASE ANALYST</td>
<td>[0.042439297, 0.08193651, -0.027909847, 0.0143...</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">62939</th>
<td>ERIN</td>
<td>FRISCO</td>
<td>75035</td>
<td>25.0</td>
<td>NONPROFIT</td>
<td>[-0.052989695, 0.19739158, 0.007059781, 0.0288...</td>
</tr>
</tbody>
</table>

</div>
    <div class="colab-df-buttons">

  <div class="colab-df-container">
    <button class="colab-df-convert" onclick="convertToInteractive('df-4ec6b342-a3ca-4e2e-9f4e-abee488afd11')" title="Convert this dataframe to an interactive table." style="display:none;">

  <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 -960 960 960">
    <path d="M120-120v-720h720v720H120Zm60-500h600v-160H180v160Zm220 220h160v-160H400v160Zm0 220h160v-160H400v160ZM180-400h160v-160H180v160Zm440 0h160v-160H620v160ZM180-180h160v-160H180v160Zm440 0h160v-160H620v160Z"></path>
  </svg>
    </button>

  <style>
    .colab-df-container {
      display:flex;
      gap: 12px;
    }

    .colab-df-convert {
      background-color: #E8F0FE;
      border: none;
      border-radius: 50%;
      cursor: pointer;
      display: none;
      fill: #1967D2;
      height: 32px;
      padding: 0 0 0 0;
      width: 32px;
    }

    .colab-df-convert:hover {
      background-color: #E2EBFA;
      box-shadow: 0px 1px 2px rgba(60, 64, 67, 0.3), 0px 1px 3px 1px rgba(60, 64, 67, 0.15);
      fill: #174EA6;
    }

    .colab-df-buttons div {
      margin-bottom: 4px;
    }

    [theme=dark] .colab-df-convert {
      background-color: #3B4455;
      fill: #D2E3FC;
    }

    [theme=dark] .colab-df-convert:hover {
      background-color: #434B5C;
      box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
      filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
      fill: #FFFFFF;
    }
  </style>

    <script>
      const buttonEl =
        document.querySelector('#df-4ec6b342-a3ca-4e2e-9f4e-abee488afd11 button.colab-df-convert');
      buttonEl.style.display =
        google.colab.kernel.accessAllowed ? 'block' : 'none';

      async function convertToInteractive(key) {
        const element = document.querySelector('#df-4ec6b342-a3ca-4e2e-9f4e-abee488afd11');
        const dataTable =
          await google.colab.kernel.invokeFunction('convertToInteractive',
                                                    [key], {});
        if (!dataTable) return;

        const docLinkHtml = 'Like what you see? Visit the ' +
          '<a target="_blank" href=https://colab.research.google.com/notebooks/data_table.ipynb>data table notebook</a>'
          + ' to learn more about interactive tables.';
        element.innerHTML = '';
        dataTable['output_type'] = 'display_data';
        await google.colab.output.renderOutput(dataTable, element);
        const docLink = document.createElement('div');
        docLink.innerHTML = docLinkHtml;
        element.appendChild(docLink);
      }
    </script>
  </div>


    <div id="df-9dfa3d08-6fd7-4ec8-972e-2f6cdb1bc3d6">
      <button class="colab-df-quickchart" onclick="quickchart('df-9dfa3d08-6fd7-4ec8-972e-2f6cdb1bc3d6')" title="Suggest charts" style="display:none;">

<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 0 24 24" width="24px">
    <g>
        <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"></path>
    </g>
</svg>
      </button>

<style>
  .colab-df-quickchart {
      --bg-color: #E8F0FE;
      --fill-color: #1967D2;
      --hover-bg-color: #E2EBFA;
      --hover-fill-color: #174EA6;
      --disabled-fill-color: #AAA;
      --disabled-bg-color: #DDD;
  }

  [theme=dark] .colab-df-quickchart {
      --bg-color: #3B4455;
      --fill-color: #D2E3FC;
      --hover-bg-color: #434B5C;
      --hover-fill-color: #FFFFFF;
      --disabled-bg-color: #3B4455;
      --disabled-fill-color: #666;
  }

  .colab-df-quickchart {
    background-color: var(--bg-color);
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: none;
    fill: var(--fill-color);
    height: 32px;
    padding: 0;
    width: 32px;
  }

  .colab-df-quickchart:hover {
    background-color: var(--hover-bg-color);
    box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
    fill: var(--button-hover-fill-color);
  }

  .colab-df-quickchart-complete:disabled,
  .colab-df-quickchart-complete:disabled:hover {
    background-color: var(--disabled-bg-color);
    fill: var(--disabled-fill-color);
    box-shadow: none;
  }

  .colab-df-spinner {
    border: 2px solid var(--fill-color);
    border-color: transparent;
    border-bottom-color: var(--fill-color);
    animation:
      spin 1s steps(1) infinite;
  }

  @keyframes spin {
    0% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
      border-left-color: var(--fill-color);
    }
    20% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    30% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
      border-right-color: var(--fill-color);
    }
    40% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    60% {
      border-color: transparent;
      border-right-color: var(--fill-color);
    }
    80% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-bottom-color: var(--fill-color);
    }
    90% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
    }
  }
</style>

      <script>
        async function quickchart(key) {
          const quickchartButtonEl =
            document.querySelector('#' + key + ' button');
          quickchartButtonEl.disabled = true;  // To prevent multiple clicks.
          quickchartButtonEl.classList.add('colab-df-spinner');
          try {
            const charts = await google.colab.kernel.invokeFunction(
                'suggestCharts', [key], {});
          } catch (error) {
            console.error('Error during call to suggestCharts:', error);
          }
          quickchartButtonEl.classList.remove('colab-df-spinner');
          quickchartButtonEl.classList.add('colab-df-quickchart-complete');
        }
        (() => {
          let quickchartButtonEl =
            document.querySelector('#df-9dfa3d08-6fd7-4ec8-972e-2f6cdb1bc3d6 button');
          quickchartButtonEl.style.display =
            google.colab.kernel.accessAllowed ? 'block' : 'none';
        })();
      </script>
    </div>

    </div>
  </div>
</div>
</div>
<p>So now we have two sets of embeddings: we have our O*NET embeddings and we have our ActBlue donor embeddings. Just as before, we can calculate similaries between them. But unlike before, we need to calculate <em>a lot</em>. In order to find the best match we need to compare every O*NET embedding vector with every ActBlue embedding vector. That’s a lot of comparisons. The good news, this is what GPUs are good at, and a free-tier GPU in Google Colab can kick this out fast.</p>
<p>We convert our O*NET <code>embedding</code> column into a (n × d) tensor, where <code>n</code> is the number of rows and <code>d</code> is the vector length. Similarly, we convert the ActBlue <code>embedding</code> column into a (m × d) tensor where <code>m</code> is the number of ActBlue rows.</p>
<p>When pushing this to a GPU, it’s a little more art than science. We batch it, and picking an optimal batch size can take some trial and error. For every batch, we’ll calculate the dot product, and return the indices of the best similarities.</p>
<p>From there, we can map all the way back to our original O*NET <code>Title</code> column, as our normalized output:</p>
<div id="cell-22" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;,&quot;height&quot;:363}}" data-outputid="df9fc12c-177f-4d80-cfa8-8bd72bfac643" data-execution_count="10">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb11" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb11-1"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> torch</span>
<span id="cb11-2"></span>
<span id="cb11-3">device <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"cuda"</span>  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># requires an NVIDIA GPU + CUDA</span></span>
<span id="cb11-4">onet_t <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> torch.tensor(np.stack(onet_df.embedding), device<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>device)  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># (n × d)</span></span>
<span id="cb11-5">abtx_t <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> torch.tensor(np.stack(abtx_df.embedding), device<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>device)  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># (m × d)</span></span>
<span id="cb11-6"></span>
<span id="cb11-7">batch <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">4096</span>  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># tune to fit GPU RAM</span></span>
<span id="cb11-8">best <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> []</span>
<span id="cb11-9"></span>
<span id="cb11-10"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">with</span> torch.no_grad():</span>
<span id="cb11-11">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">for</span> s <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">range</span>(<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>, abtx_t.size(<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>), batch):</span>
<span id="cb11-12">        sims <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> abtx_t[s:s<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">+</span>batch] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">@</span> onet_t.T  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># (batch × n)</span></span>
<span id="cb11-13">        best.append(sims.argmax(dim<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>).cpu())</span>
<span id="cb11-14"></span>
<span id="cb11-15">idx <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> torch.cat(best).numpy()</span>
<span id="cb11-16"></span>
<span id="cb11-17">abtx_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Normalized Occupation"</span>] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> onet_df.Title.iloc[idx].to_numpy()</span>
<span id="cb11-18"></span>
<span id="cb11-19">get_sample(<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">30330</span>)[[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"first_name"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"occupation"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Normalized Occupation"</span>]]</span></code></pre></div></div>
<div class="cell-output cell-output-display" data-execution_count="10">
<div id="df-a1e005cf-36c2-433f-99a9-113a3ec71d4a" class="colab-df-container">
    <div>


<table class="dataframe caption-top table table-sm table-striped small" data-border="1">
<thead>
<tr class="header">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">first_name</th>
<th data-quarto-table-cell-role="th">occupation</th>
<th data-quarto-table-cell-role="th">Normalized Occupation</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<th data-quarto-table-cell-role="th">139133</th>
<td>PAT</td>
<td>RETIRED</td>
<td>Retired</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">4248</th>
<td>HARRY</td>
<td>LAWYER</td>
<td>Lawyers</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">221073</th>
<td>THAO</td>
<td>HISTOLOGY TECHNICIAN</td>
<td>Histology Technicians</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">201669</th>
<td>PHILIP</td>
<td>MANAGER</td>
<td>Managers, All Other</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">256487</th>
<td>MARIA</td>
<td>HEALTHCARE ADMINISTRATOR</td>
<td>Medical and Health Services Managers</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">6330</th>
<td>ELLEN</td>
<td>ARBITRATOR</td>
<td>Arbitrators, Mediators, and Conciliators</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">125976</th>
<td>MICHELLE</td>
<td>SERVER</td>
<td>Food Servers, Nonrestaurant</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">13659</th>
<td>ROSE</td>
<td>NURSING</td>
<td>Registered Nurses</td>
</tr>
<tr class="odd">
<th data-quarto-table-cell-role="th">138213</th>
<td>PETER</td>
<td>DATABASE ANALYST</td>
<td>Database Administrators</td>
</tr>
<tr class="even">
<th data-quarto-table-cell-role="th">62939</th>
<td>ERIN</td>
<td>NONPROFIT</td>
<td>Fundraisers</td>
</tr>
</tbody>
</table>

</div>
    <div class="colab-df-buttons">

  <div class="colab-df-container">
    <button class="colab-df-convert" onclick="convertToInteractive('df-a1e005cf-36c2-433f-99a9-113a3ec71d4a')" title="Convert this dataframe to an interactive table." style="display:none;">

  <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 -960 960 960">
    <path d="M120-120v-720h720v720H120Zm60-500h600v-160H180v160Zm220 220h160v-160H400v160Zm0 220h160v-160H400v160ZM180-400h160v-160H180v160Zm440 0h160v-160H620v160ZM180-180h160v-160H180v160Zm440 0h160v-160H620v160Z"></path>
  </svg>
    </button>

  <style>
    .colab-df-container {
      display:flex;
      gap: 12px;
    }

    .colab-df-convert {
      background-color: #E8F0FE;
      border: none;
      border-radius: 50%;
      cursor: pointer;
      display: none;
      fill: #1967D2;
      height: 32px;
      padding: 0 0 0 0;
      width: 32px;
    }

    .colab-df-convert:hover {
      background-color: #E2EBFA;
      box-shadow: 0px 1px 2px rgba(60, 64, 67, 0.3), 0px 1px 3px 1px rgba(60, 64, 67, 0.15);
      fill: #174EA6;
    }

    .colab-df-buttons div {
      margin-bottom: 4px;
    }

    [theme=dark] .colab-df-convert {
      background-color: #3B4455;
      fill: #D2E3FC;
    }

    [theme=dark] .colab-df-convert:hover {
      background-color: #434B5C;
      box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
      filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
      fill: #FFFFFF;
    }
  </style>

    <script>
      const buttonEl =
        document.querySelector('#df-a1e005cf-36c2-433f-99a9-113a3ec71d4a button.colab-df-convert');
      buttonEl.style.display =
        google.colab.kernel.accessAllowed ? 'block' : 'none';

      async function convertToInteractive(key) {
        const element = document.querySelector('#df-a1e005cf-36c2-433f-99a9-113a3ec71d4a');
        const dataTable =
          await google.colab.kernel.invokeFunction('convertToInteractive',
                                                    [key], {});
        if (!dataTable) return;

        const docLinkHtml = 'Like what you see? Visit the ' +
          '<a target="_blank" href=https://colab.research.google.com/notebooks/data_table.ipynb>data table notebook</a>'
          + ' to learn more about interactive tables.';
        element.innerHTML = '';
        dataTable['output_type'] = 'display_data';
        await google.colab.output.renderOutput(dataTable, element);
        const docLink = document.createElement('div');
        docLink.innerHTML = docLinkHtml;
        element.appendChild(docLink);
      }
    </script>
  </div>


    <div id="df-1f02a267-ad0f-480d-915e-5dc79eb9a6a2">
      <button class="colab-df-quickchart" onclick="quickchart('df-1f02a267-ad0f-480d-915e-5dc79eb9a6a2')" title="Suggest charts" style="display:none;">

<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 0 24 24" width="24px">
    <g>
        <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"></path>
    </g>
</svg>
      </button>

<style>
  .colab-df-quickchart {
      --bg-color: #E8F0FE;
      --fill-color: #1967D2;
      --hover-bg-color: #E2EBFA;
      --hover-fill-color: #174EA6;
      --disabled-fill-color: #AAA;
      --disabled-bg-color: #DDD;
  }

  [theme=dark] .colab-df-quickchart {
      --bg-color: #3B4455;
      --fill-color: #D2E3FC;
      --hover-bg-color: #434B5C;
      --hover-fill-color: #FFFFFF;
      --disabled-bg-color: #3B4455;
      --disabled-fill-color: #666;
  }

  .colab-df-quickchart {
    background-color: var(--bg-color);
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: none;
    fill: var(--fill-color);
    height: 32px;
    padding: 0;
    width: 32px;
  }

  .colab-df-quickchart:hover {
    background-color: var(--hover-bg-color);
    box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
    fill: var(--button-hover-fill-color);
  }

  .colab-df-quickchart-complete:disabled,
  .colab-df-quickchart-complete:disabled:hover {
    background-color: var(--disabled-bg-color);
    fill: var(--disabled-fill-color);
    box-shadow: none;
  }

  .colab-df-spinner {
    border: 2px solid var(--fill-color);
    border-color: transparent;
    border-bottom-color: var(--fill-color);
    animation:
      spin 1s steps(1) infinite;
  }

  @keyframes spin {
    0% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
      border-left-color: var(--fill-color);
    }
    20% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    30% {
      border-color: transparent;
      border-left-color: var(--fill-color);
      border-top-color: var(--fill-color);
      border-right-color: var(--fill-color);
    }
    40% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-top-color: var(--fill-color);
    }
    60% {
      border-color: transparent;
      border-right-color: var(--fill-color);
    }
    80% {
      border-color: transparent;
      border-right-color: var(--fill-color);
      border-bottom-color: var(--fill-color);
    }
    90% {
      border-color: transparent;
      border-bottom-color: var(--fill-color);
    }
  }
</style>

      <script>
        async function quickchart(key) {
          const quickchartButtonEl =
            document.querySelector('#' + key + ' button');
          quickchartButtonEl.disabled = true;  // To prevent multiple clicks.
          quickchartButtonEl.classList.add('colab-df-spinner');
          try {
            const charts = await google.colab.kernel.invokeFunction(
                'suggestCharts', [key], {});
          } catch (error) {
            console.error('Error during call to suggestCharts:', error);
          }
          quickchartButtonEl.classList.remove('colab-df-spinner');
          quickchartButtonEl.classList.add('colab-df-quickchart-complete');
        }
        (() => {
          let quickchartButtonEl =
            document.querySelector('#df-1f02a267-ad0f-480d-915e-5dc79eb9a6a2 button');
          quickchartButtonEl.style.display =
            google.colab.kernel.accessAllowed ? 'block' : 'none';
        })();
      </script>
    </div>

    </div>
  </div>
</div>
</div>
<p>A way to visualized this is with <a href="https://en.wikipedia.org/wiki/Principal_component_analysis">principal component analysis</a>. PCA computes new orthogonal axes called principal components that capture the most variation in the data. These directions are combinations of the original dimensions, chosen to reveal the biggest patterns and differences. By projecting each vector onto the first two principal components, we can plot everything in two dimensions while keeping as much of the original structure as possible:</p>
<div id="cell-24" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;,&quot;height&quot;:906}}" data-outputid="53040c7b-649a-4120-9918-3d6cd337f116" data-execution_count="11">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb12" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb12-1"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> matplotlib.pyplot <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">as</span> plt</span>
<span id="cb12-2"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">from</span> sklearn.decomposition <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> PCA</span>
<span id="cb12-3"></span>
<span id="cb12-4">X_onet <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> np.vstack(onet_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"embedding"</span>].to_numpy())</span>
<span id="cb12-5">X_abtx <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> np.vstack(abtx_df[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"embedding"</span>].to_numpy())</span>
<span id="cb12-6">X_all  <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> np.vstack([X_onet, X_abtx])</span>
<span id="cb12-7"></span>
<span id="cb12-8">pca <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> PCA(n_components<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span>)</span>
<span id="cb12-9">proj <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> pca.fit_transform(X_all)</span>
<span id="cb12-10">coords_onet <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> proj[: <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">len</span>(X_onet)]</span>
<span id="cb12-11">coords_abtx <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> proj[<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">len</span>(X_onet) :]</span>
<span id="cb12-12"></span>
<span id="cb12-13">plt.figure(figsize<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>(<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">16</span>, <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">9</span>))</span>
<span id="cb12-14">plt.scatter(coords_onet[:, <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>], coords_onet[:, <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>], alpha<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">0.6</span>, label<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"O*NET job titles"</span>)</span>
<span id="cb12-15">plt.scatter(coords_abtx[:, <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>], coords_abtx[:, <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>], alpha<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">0.6</span>, label<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Donor-entered occupations"</span>)</span>
<span id="cb12-16">plt.title(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"PCA of Occupation Embeddings: O*NET vs Donor-entered"</span>)</span>
<span id="cb12-17">plt.xlabel(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Component 1"</span>)</span>
<span id="cb12-18">plt.ylabel(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Component 2"</span>)</span>
<span id="cb12-19">plt.legend()</span>
<span id="cb12-20">plt.tight_layout()</span>
<span id="cb12-21">plt.show()</span></code></pre></div></div>
<div class="cell-output cell-output-display">
<div>
<figure class="figure">
<p><img src="https://matthodges.com/posts/2025-08-02-language-model-embeddings-campaign-donors/index_files/figure-html/cell-12-output-1.png" class="img-fluid figure-img"></p>
</figure>
</div>
</div>
</div>
<p>And in our higher-dimension space, we can calculate ad hoc similarities on any arbitrary job title someone might enter:</p>
<div id="cell-26" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;}}" data-outputid="ef85c9d4-0725-44ce-bfe3-81ae2be42c31" data-execution_count="12">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb13" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb13-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">def</span> normalize(job_title):</span>
<span id="cb13-2">  ad_hoc <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> torch.tensor(</span>
<span id="cb13-3">      model.encode(</span>
<span id="cb13-4">          [job_title],</span>
<span id="cb13-5">          normalize_embeddings<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>,</span>
<span id="cb13-6">          convert_to_numpy<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>,</span>
<span id="cb13-7">          show_progress_bar<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">False</span>,</span>
<span id="cb13-8">      ),</span>
<span id="cb13-9">      device<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>device,</span>
<span id="cb13-10">  )</span>
<span id="cb13-11">  idx <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">int</span>((ad_hoc <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">@</span> onet_t.T).argmax(dim<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>))</span>
<span id="cb13-12">  <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">return</span> <span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">f'</span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">{</span>job_title<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">}</span><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;"> → </span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">{</span>onet_df<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span>at[idx, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Title"</span>]<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">}</span><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">'</span></span>
<span id="cb13-13"></span>
<span id="cb13-14"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"code ninja"</span>))</span>
<span id="cb13-15"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"uber"</span>))</span>
<span id="cb13-16"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"GM"</span>))</span>
<span id="cb13-17"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Professor, Artist"</span>))</span>
<span id="cb13-18"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Postdoctoral Fellow"</span>))</span>
<span id="cb13-19"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"DARE Officer"</span>))</span>
<span id="cb13-20"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Senator"</span>))</span>
<span id="cb13-21"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Comms Director"</span>))</span>
<span id="cb13-22"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"dermatology"</span>))</span>
<span id="cb13-23"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"commodities trader"</span>))</span>
<span id="cb13-24"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"life insurance adjuster"</span>))</span>
<span id="cb13-25"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"lumberjack"</span>))</span>
<span id="cb13-26"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"cpa"</span>))</span></code></pre></div></div>
<div class="cell-output cell-output-stdout">
<pre><code>code ninja → Computer Programmers
uber → Taxi Drivers
GM → General and Operations Managers
Professor, Artist → Art, Drama, and Music Teachers, Postsecondary
Postdoctoral Fellow → Clinical Research Coordinators
DARE Officer → Police and Sheriff's Patrol Officers
Senator → Legislators
Comms Director → Public Relations Managers
dermatology → Dermatologists
commodities trader → Securities, Commodities, and Financial Services Sales Agents
life insurance adjuster → Claims Adjusters, Examiners, and Investigators
lumberjack → Fallers
cpa → Accountants and Auditors</code></pre>
</div>
</div>
<p>It’s not perfect. I don’t think I would have normalized <strong>Postdoctoral Fellow</strong> to <strong>Clinical Research Coordinators</strong>. This is where decisions we made in constructing the model start to show themselves. All those <code>aka</code> job title concatenations we did at the top affected how the language was modeled. Depending on your use case this could be fine; maybe you just need deterministic finite categories. You can play with the embedding calculation process to see how different strategies yield different resules.</p>
<p>As for our nurses:</p>
<div id="cell-28" class="cell" data-quarto-private-1="{&quot;key&quot;:&quot;colab&quot;,&quot;value&quot;:{&quot;base_uri&quot;:&quot;https://localhost:8080/&quot;}}" data-outputid="f4243eb0-6ba7-46a8-8e78-4d9ae71fcbcc" data-execution_count="13">
<div class="code-copy-outer-scaffold"><div class="sourceCode cell-code" id="cb15" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb15-1"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"nurse"</span>))</span>
<span id="cb15-2"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"RN"</span>))</span>
<span id="cb15-3"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"MSN"</span>))</span>
<span id="cb15-4"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"ER Nurse"</span>))</span>
<span id="cb15-5"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(normalize(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"home health nursing"</span>))</span></code></pre></div></div>
<div class="cell-output cell-output-stdout">
<pre><code>nurse → Registered Nurses
RN → Registered Nurses
MSN → Registered Nurses
ER Nurse → Registered Nurses
home health nursing → Registered Nurses</code></pre>
</div>
</div>
<p>Language models are pretty good at modeling language!</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2025-08-02-language-model-embeddings-campaign-donors/</guid>
  <pubDate>Sat, 02 Aug 2025 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2025-08-02-language-model-embeddings-campaign-donors/pca-onet-donor-entered.png" medium="image" type="image/png" height="81" width="144"/>
</item>
<item>
  <title>GPT Finally Jumps Out of the System</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2025-04-21-openai-o4-mini-high-mu-puzzle/</link>
  <description><![CDATA[ 





<p>Well, they’ve done it. OpenAI released a model that can solve the <a href="https://en.wikipedia.org/wiki/MU_puzzle">MU Puzzle</a>. This has been my go-to test for GPTs since before ChatGPT was launched and models were only available in the Playground.</p>
<p>If you’re unfamiliar with the puzzle, it was introduced by <a href="https://en.wikipedia.org/wiki/Douglas_Hofstadter">Douglas Hofstadter</a> in his Pulitzer-winning book <a href="https://en.wikipedia.org/wiki/G%C3%B6del,_Escher,_Bach">Gödel, Escher, Bach</a>. The book explores how meaning can emerge from symbolic manipulation, and how formal systems like mathematics can be simultaneously powerful and fundamentally incomplete. The MU Puzzle, introduced early in the book, isn’t just a quirky game. It’s a microcosm of formal systems. It was designed to make you feel clever while gently exposing the limits of mechanical reasoning. And its rules are simple:</p>
<p>Suppose there are the symbols <code>M</code>, <code>I</code>, and <code>U</code> which can be combined to produce strings of symbols. The MU puzzle asks one to start with the axiomatic string <code>MI</code> and transform it into the string <code>MU</code> using any combination of the available transformation rules:</p>
<table class="caption-top table">
<colgroup>
<col style="width: 4%">
<col style="width: 16%">
<col style="width: 59%">
<col style="width: 19%">
</colgroup>
<thead>
<tr class="header">
<th>Rule</th>
<th>Formal</th>
<th>Informal</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>1</td>
<td>x<code>I</code> → x<code>IU</code></td>
<td>Add <code>U</code> to the end of any string that currently ends in <code>I</code>.</td>
<td><code>MI</code> to <code>MIU</code></td>
</tr>
<tr class="even">
<td>2</td>
<td><code>M</code>x → <code>M</code>xx</td>
<td>Double the portion of the string that follows <code>M</code>.</td>
<td><code>MIU</code> to <code>MIUIU</code></td>
</tr>
<tr class="odd">
<td>3</td>
<td>x<code>III</code>y → x<code>U</code>y</td>
<td>Replace any single instance of the substring <code>III</code> with <code>U</code>.</td>
<td><code>MUIIIU</code> to <code>MUUU</code></td>
</tr>
<tr class="even">
<td>4</td>
<td>x<code>UU</code>y → xy</td>
<td>Remove any <code>UU</code>.</td>
<td><code>MUUU</code> to <code>MU</code></td>
</tr>
</tbody>
</table>
<p>If you’ve never seen this puzzle before, you should stop for a few minutes and think on it. Grab a pen and paper and see what you can come up with. I’ve tucked the solution behind this spoiler:</p>
<div class="callout callout-style-simple callout-note no-icon callout-titled">
<div class="callout-header d-flex align-content-center collapsed" data-bs-toggle="collapse" data-bs-target=".callout-1-contents" aria-controls="callout-1" aria-expanded="false" aria-label="Toggle callout">
<div class="callout-icon-container">
<i class="callout-icon no-icon"></i>
</div>
<div class="callout-title-container flex-fill">
<span class="screen-reader-only">Note</span>Reveal Solution
</div>
<div class="callout-btn-toggle d-inline-block border-0 py-1 ps-1 pe-0 float-end"><i class="callout-toggle"></i></div>
</div>
<div id="callout-1" class="callout-1-contents callout-collapse collapse">
<div class="callout-body-container callout-body">
<p><strong>Solution:</strong> It can’t be done! You might be grumpy that this was a trick question, or you might have tried a few steps and intuitively come to this conclusion. We’ll discuss more formally why the task is impossible later.</p>
</div>
</div>
</div>
<p>I like to use the MU Puzzle as a model test because until now, I’ve never seen any GPT come to the correct answer. Some of them stumble into the <em>expected</em> answer but give flawed explanations. Others come to the wrong answer, usually by violating one of the rules. Even now, <a href="https://openai.com/index/introducing-o3-and-o4-mini/">o3</a>, currently billed as, “our most powerful reasoning model that pushes the frontier across coding, math, science, visual perception, and more” fails the puzzle.</p>
<p>When I give the puzzle to a model, I swap in different letters and present the rules conversationally. I do this to try to defend against the model regurgitation from GEB or Wikipedia. In my case, <code>M</code> becomes <code>A</code>, <code>I</code> becomes <code>B</code>, and <code>U</code> becomes <code>C</code>.</p>
<p>My prompt:</p>
<div class="long-quote">
<p>Let’s play a game. You have three symbols: “A”, “B”, and “C” which you can combine to produce strings. You start with the string “AB” and your challenge is to transform it to the string “AC”. To transform it, you can use a series of transformation rules. You don’t have to use every rule, and you may use any rule multiple times. Here are the available transformation rules: Rule 1: You may add “C” to the end of any string that currently ends in “B”. For example, you may transform “AB” to “ABC”. Rule 2: You may double the portion of the string that follows “A”. For example, you may transform “ABC” to “ABCBC”. Rule 3: You may replace any single instance of the substring “BBB” with “C”. For example, you may transform “ACBBBC” to “ACCC”. Rule 4: You may remove any single instance of the substring “CC”. For example, you may transform “ACCC” to “AC”. Your task is to come up with a series of transformation steps to convert the string “AB” to “AC”. Please share what steps can solve this.</p>
</div>
<p>After <a href="https://chatgpt.com/share/68070559-e284-8002-9f44-d0bd8ace53e1">thinking</a> for <strong>3 minutes and 11 seconds</strong> o3 declared victory:</p>
<div class="long-quote">
<p>Here’s a clean sequence that turns <code>AB</code> into <code>AC</code>, using only the allowed rules. I annotate each step with the rule that was applied.</p>
<table class="caption-top table">
<colgroup>
<col style="width: 20%">
<col style="width: 27%">
<col style="width: 51%">
</colgroup>
<thead>
<tr class="header">
<th>Step</th>
<th>String</th>
<th>What happened</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>0</td>
<td><code>AB</code></td>
<td>start</td>
</tr>
<tr class="even">
<td>1</td>
<td><code>ABB</code></td>
<td>Rule&nbsp;2 – doubled everything after <code>A</code> (“B” → “BB”)</td>
</tr>
<tr class="odd">
<td>2</td>
<td><code>ABBBB</code></td>
<td>Rule&nbsp;2 again – doubled “BB” → “BBBB”</td>
</tr>
<tr class="even">
<td>3</td>
<td><code>ABBBBBBBB</code></td>
<td>Rule&nbsp;2 a third time – doubled “BBBB” → “BBBBBBBB” (now 8&nbsp;B’s in a row)</td>
</tr>
<tr class="odd">
<td>4</td>
<td><code>ACBBBBBB</code></td>
<td>Rule&nbsp;3 – replaced the first “BBB” with “C”</td>
</tr>
<tr class="even">
<td>5</td>
<td><code>ACCBBB</code></td>
<td>Rule&nbsp;3 – replaced the next “BBB” with “C”</td>
</tr>
<tr class="odd">
<td>6</td>
<td><code>ACCC</code></td>
<td>Rule&nbsp;3 – replaced the last “BBB” with “C”</td>
</tr>
<tr class="even">
<td>7</td>
<td><code>AC</code></td>
<td>Rule&nbsp;4 – removed one “CC” pair</td>
</tr>
</tbody>
</table>
<p>After step&nbsp;7 the string is exactly <code>AC</code>, so the puzzle is solved.</p>
</div>
<p>See the problem?</p>
<p>Step Four violates Rule 3. After Step Three, the string is <code>ABBBBBBBB</code>. o3 then claimed to apply “Rule 3 – replaced the first ‘BBB’ with ‘C’” to yield <code>ACBBBBBB</code>. But it didn’t replace the first ‘BBB’. It only replaced the first ‘BB’.</p>
<p>This kind of error is characteristic of a broader failure mode seen across GPT models: a tendency toward <em>approximate rule-matching</em> rather than strict symbolic fidelity. The model recognizes the general shape of a rule, in this case, something about repeating <code>B</code>s being collapsible, but it doesn’t reliably enforce the exact conditions. Instead of replacing a full <code>BBB</code> triplet, it replaced only two <code>B</code>s, violating Rule 3 while still producing something that looks right. This isn’t just a hallucination; it’s more like a form of <em>syntactic overgeneralization</em>. It suggests that the model is reasoning through fuzzy analogies to rules, rather than maintaining crisp internal representations of symbolic constraints.</p>
<p>This is exactly the kind of reasoning Hofstadter warned about: systems that operate on syntactic structure without deeper semantic control can seem smart, until they aren’t.</p>
<p>The other bad answer I’ve seen is the model will try for a while, generating endlessly long strings, and then just give up. It’ll declare “unsolvable!” on the grounds of it tried for a while.</p>
<p>But <strong>o4-mini-high</strong> got it. After <a href="https://chatgpt.com/share/68070581-856c-8002-833d-a7385adb7c02">thinking</a> for <strong>1 minute and 40 seconds</strong>:</p>
<div class="long-quote">
<p>Let’s first look at what these rules do to the number of&nbsp;B’s in your string:</p>
<ul>
<li><strong>Rule&nbsp;2</strong> (doubling after&nbsp;A) takes a string with (b)&nbsp;B’s and turns it into one with (2b)&nbsp;B’s.<br>
</li>
<li><strong>Rule&nbsp;3</strong> (replacing “BBB” with “C”) removes exactly&nbsp;3&nbsp;B’s.<br>
</li>
<li>Rules&nbsp;1 and&nbsp;4 never touch the B‑count.</li>
</ul>
<p>So if you start with exactly&nbsp;1&nbsp;B (in “AB”), then after using Rule&nbsp;2 (k)&nbsp;times and Rule&nbsp;3 (m)&nbsp;times (in any order), the total B‑count will be<br>
<img src="https://latex.codecogs.com/png.latex?%0A%20%20%201%5Ccdot2%5Ek%20%5C;-%5C;3%5C,m%0A"> because you’ve doubled the B’s (k)&nbsp;times and subtracted three B’s each time you applied Rule&nbsp;3. To end up with zero B’s (as you must, since “AC” has no B’s) you’d need<br>
<img src="https://latex.codecogs.com/png.latex?%0A%20%20%202%5Ek%20%5C;=%5C;3%5C,m.%0A"> But <img src="https://latex.codecogs.com/png.latex?2%5Ek"> is never divisible by&nbsp;3, so there is <strong>no</strong> choice of nonnegative integers <img src="https://latex.codecogs.com/png.latex?k,m"> making <img src="https://latex.codecogs.com/png.latex?2%5Ek=3m">. In other words, you can never get rid of that single initial B; no finite sequence of these rules will turn “AB” into “AC.”</p>
<p><strong>Conclusion:</strong><br>
There is <strong>no</strong> sequence of applications of Rules&nbsp;1–4 that transforms “AB” into “AC.”</p>
</div>
<p>Not only is this the correct answer, but it’s the correct <em>reasoning</em> behind the answer.</p>
<p>Hofstadter used the MU Puzzle to demonstrate the contrast between interpretation on the syntactic level of symbols and on the semantic level of meanings. This seems very relevant to studying LLMs!</p>
<p>When dealing only in syntax there is nothing that would cause the LLM to infer the puzzle’s insolvability. It could churn away within the system, generating longer and longer valid strings, and never actually show that <code>MU</code> (or, <code>AC</code>) can’t be created.</p>
<p>If you’re like me, you probably tried a number of different iterations before eventually suspecting that the puzzle might not have a solution. Hofstadter referred to this as, “jumping out of the system”, the idea of reasoning about the system, rather than working within it. Depending on how long you toiled at the game, you might have discovered the ability (or inability) to divide by 3 is key to the process. And at that point, you would be thinking on the semantic level of the system.</p>
<p>Much of GEB is built around the idea of the MU Puzzle, and it’s used as a grounding point to explain <a href="https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_theorems">Gödel’s Incompleteness Theorems</a>. But an approachable explanation to its insolvability is:</p>
<ul>
<li>Our desired string contains zero <code>I</code>s; 0 is divisible by 3.</li>
<li>Our starting string contains one <code>I</code>; 1 is not divisible by 3</li>
<li>Only Rule 2 and Rule 3 change the number of <code>I</code>s.</li>
<li>Rule 2 doubles the number of <code>I</code>s.</li>
<li>Doubling a number that is not divisible by 3 does not make it divisible by 3.</li>
<li>Rule 3 reduces the number of <code>I</code> by 3.</li>
<li>Subtracting 3 from a number that is not divisible by 3 does not make it divisible by 3.</li>
</ul>
<p>Since there is no series of transformations that make the number of <code>I</code>s divisible by 3, there is no series of transformations that produce <code>MU</code>.</p>
<p>Which is exactly what <strong>o4-mini-high</strong> said!</p>
<p>This echoes Hofstadter’s deeper point: the MU Puzzle is a toy model for Gödel’s Incompleteness Theorems. Within the formal system of applying the four rules mechanically, there’s no way to derive the string <code>MU</code>. But from outside the system, we can <em>see</em> and <em>prove</em> that no such derivation exists. The puzzle’s unsolvability is a true statement that cannot be proven from within the rules themselves. That’s Gödel’s move: constructing a statement that is unprovable from within the system, yet evidently true when viewed from a meta-level. What’s striking is that <strong>o4-mini-high</strong> not only refused to be tricked into endless symbol manipulation, but it also <em>appeared</em> to jumped to this higher level of reasoning — the “semantic” frame — and explained <em>why</em> the goal was unreachable.</p>
<p>I’m not claiming AGI. Maybe the tensors just memorized enough puzzles of this shape. But I’ve tested this one on dozens of models over the years, and this is the first time I’ve seen a GPT both identify the impossibility <em>and</em> explain why. I think that’s pretty cool.</p>
<p>Computers!</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2025-04-21-openai-o4-mini-high-mu-puzzle/</guid>
  <pubDate>Mon, 21 Apr 2025 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2025-04-21-openai-o4-mini-high-mu-puzzle/openai-o4-mini-high-mu-puzzle.jpeg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>Part 2: Austin Texas, Hot or Not?</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2024-12-26-austin-hot-or-not-part-2/</link>
  <description><![CDATA[ 





<p>Over the Summer I <a href="https://matthodges.com/posts/2024-07-30-austin-hot-or-not/">wondered</a> whether Austin was having a cooler season than normal. Through the magic of public weather data, I was able to discover that the answer was mostly yes! But I also wrote that post at the end of July, and a lot of 2024 was still left to go. So here I am, sitting at my kitchen counter the day after Christmas, ready to definitively answer the question for the year: Austin, hot or not? Code for how all of this (mostly) works is in the original post, so this update is relatively brief with only the graphs.</p>
<p>First, I wanted to quickly glance at 2023 vs 2024. For a little bit there, this Summer was cooler than last Summer. But then we had a pretty hot week in late August. Year over year, the Summer looks like it was generally cooler than last year, but the Fall has been slightly warmer:</p>
<div id="cell-7" class="cell" data-execution_count="114">
<div class="cell-output cell-output-display">
<div>
<figure class="figure">
<p><img src="https://matthodges.com/posts/2024-12-26-austin-hot-or-not-part-2/index_files/figure-html/cell-6-output-1.svg" class="img-fluid figure-img"></p>
</figure>
</div>
</div>
</div>
<p>But comparing two years doesn’t really reveal a trend (or lack of trend) so here are 2023 and 2024 highlighted over the previous ten years. It’s kindof a mess, like if Hulk Hogan couldn’t color within the lines. But if you squint you can somewhat pick out some insights.</p>
<div id="cell-9" class="cell" data-execution_count="113">
<div class="cell-output cell-output-display">
<div>
<figure class="figure">
<p><img src="https://matthodges.com/posts/2024-12-26-austin-hot-or-not-part-2/index_files/figure-html/cell-7-output-1.svg" class="img-fluid figure-img"></p>
</figure>
</div>
</div>
</div>
<p>So here’s the final viz I landed on. For most of the Winter and Spring, Austin was oscillating around its 10-year trailing average maximum daily temperature. Then as Summer hit, max temperatures dipped a little bit below average, spiked up in mid August, and then fell again slightly below average until September. From there we’ve been mostly above average!</p>
<div id="cell-12" class="cell" data-execution_count="112">
<div class="cell-output cell-output-display">
<div>
<figure class="figure">
<p><img src="https://matthodges.com/posts/2024-12-26-austin-hot-or-not-part-2/index_files/figure-html/cell-9-output-1.svg" class="img-fluid figure-img"></p>
</figure>
</div>
</div>
</div>
<p>As a final glimpse, we can see how many 2024 days (through December 23, my last day of available data) have crossed above the average max temperature, and how many days stayed below:</p>
<div id="cell-14" class="cell" data-execution_count="109">
<div class="cell-output cell-output-stdout">
<pre><code>Above Average: 219
Below Average: 137</code></pre>
</div>
</div>
<p>Thermometers!</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2024-12-26-austin-hot-or-not-part-2/</guid>
  <pubDate>Thu, 26 Dec 2024 06:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2024-12-26-austin-hot-or-not-part-2/2024-austin-texas-max-temperatures.png" medium="image" type="image/png" height="101" width="144"/>
</item>
<item>
  <title>I Cracked And Modded the DNC Bracelet</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2024-08-28-cracking-modding-dnc-bracelet/</link>
  <description><![CDATA[ 





<p><strong>Heads up:</strong> This post has some videos that contain flashing lights.</p>
<p><img src="https://matthodges.com/posts/2024-08-28-cracking-modding-dnc-bracelet/00-dnc-teardown.jpeg" class="img-fluid"></p>
<p>If you managed to get a seat on the final night of the 2024 Democratic National Convention (I got there at 2:30 pm and still had to rely on seat-saving friends!) you probably also found an LED bracelet wrapped around your arm rest. I’m bad at remembering to take photos of things in the moment, so here’s mine over my kitchen counter:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2024-08-28-cracking-modding-dnc-bracelet/01-wristband.jpeg" class="img-fluid figure-img"></p>
<figcaption>A photo of my LED DNC bracelet that I took later</figcaption>
</figure>
</div>
<p>If you’ve been to a large concert in recent years, you may have seen this before. It’s designed to give the audience an “immersive” experience with the entertainment,<sup>1</sup> usually by activating the lights at key moments. At the 2024 DNC, that key moment of audience activation was when Vice President Kamala Harris walked out to deliver her speech. I didn’t take a photo of the crowd during that time, but everyone lit up in twinkling red, white, and blue lights. It was fun.</p>
<p>I used to work on <a href="https://lisnr.com/">immersive audience engagement tech</a> (that has since pivoted to retail payment tech), so I was curious how this worked. Luckily, I took the bracelet home so I could find out!</p>
<p>The first thing that caught my eye was <strong>PIXMOB</strong> engraved onto the back of the plastic case.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2024-08-28-cracking-modding-dnc-bracelet/02-pixmob.jpeg" class="img-fluid figure-img"></p>
<figcaption>PIXMOB engraved on the back of the DNC bracelet case</figcaption>
</figure>
</div>
<p>I had never heard of PIXMOB, but their <a href="https://pixmob.com/products/led-wristbands">website</a> gives some immediate answers. Apparently they offer three different wristband products. All of them look identical so I wasn’t sure which one I had. My first thought — as is often the case when I’m trying to learn something election-related — was to dig through some FEC filings. I spotted in the PIXMOB <a href="https://pixmob.com/terms-and-conditions">Terms and Conditions</a> that the company is actually called <strong>ESKI Inc.</strong> I spent only a few minutes clicking through the <a href="https://projects.propublica.org/itemizer/committee/C00827022/2024">Democratic National Convention Committee’s expenditures</a> without finding any mention of PIXMOB or ESKI. I suspect that the Convention Committee retained an events vendor that was a middleman to this hardware.</p>
<p>So I grabbed my <a href="https://www.adafruit.com/product/2414">Jimmy</a> and cracked the thing open.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2024-08-28-cracking-modding-dnc-bracelet/03-open.jpeg" class="img-fluid figure-img"></p>
<figcaption>Opened DNC bracelet</figcaption>
</figure>
</div>
<p>Like most consumer electronics, the thing is mostly battery! I actually wasn’t expecting AAAs, but it’s helpful to know we’re working with 3 volts and I can easily swap some in if they die.</p>
<p>At this point, I also had the <a href="https://en.wikipedia.org/wiki/PixMob">PixMob Wikipedia page open</a> (is it PIXMOB or PixMob?). It features <a href="https://en.wikipedia.org/wiki/PixMob#/media/File:PixMob_PCB_and_case,_used_in_Zurich_2024.jpg">another cracked PixMob wristband</a> that was apparently used at Taylor Swift shows. That one also sported a white PCB, but used button cell batteries.</p>
<p>Besides the batteries, I also noticed that the Taylor Swift wristband listed a <code>Palm</code> (like palm tree) version number, while mine has a <code>Diamond</code> (like shiny carbon) version number:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2024-08-28-cracking-modding-dnc-bracelet/04-diamond.jpeg" class="img-fluid figure-img"></p>
<figcaption>Opened DNC bracelet showing the Diamond version</figcaption>
</figure>
</div>
<p>They both have a copyright on them, too, and at some point I want to ask an IP attorney whether you can actually copyright a PCB or if you can only copyright their <a href="https://en.wikipedia.org/wiki/Gerber_format">Gerber files</a>. I would have guessed the appropriate protection would be a patent. I also just learned about the <a href="https://en.wikipedia.org/wiki/Semiconductor_Chip_Protection_Act_of_1984">Semiconductor Chip Protection Act of 1984</a>. But that’s a topic for another time.</p>
<p>I tried searching <a href="https://fccid.io/2ADS4">ESKI’s FCC filings</a> for some more hints about the <code>Diamond</code> board, but didn’t find anything specific. But I did find some <a href="https://fccid.io/2ADS4WASH">very interesting documents about an accompanying infrared transmitter</a>. I bet those, or something like those, were all over the the arena.</p>
<p>Moving on, the next thing I noticed about my wristband is that it has four LEDs:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2024-08-28-cracking-modding-dnc-bracelet/05-four-LEDs.jpeg" class="img-fluid figure-img"></p>
<figcaption>Opened DNC bracelet showing four LEDs</figcaption>
</figure>
</div>
<p>This means I probably have the <a href="https://pixmob.com/products/led-wristbands/x4">PixMob X4</a>, dubbed “our brightest wristband”.</p>
<p>Now that I knew what I was working with, I turned back to web search to see what prior work has happened with cracking and modding these things. Turns out, I’m not the first person to break one of these things open. <a href="https://www.youtube.com/watch?v=ziNF0seaw1k">There</a> <a href="https://www.youtube.com/watch?v=50LL-n7xZp8">are</a> <a href="https://www.youtube.com/watch?v=TuQImukbnRg">so</a> <a href="https://www.youtube.com/watch?v=TlK1jrO30PA">many</a> videos of people getting these things to obey. And there are some <a href="https://github.com/danielweidman/pixmob-ir-reverse-engineering?tab=readme-ov-file">really cool GitHub repos</a> demonstrating how to use <a href="https://www.arduino.cc/">Arduinos</a> or <a href="https://flipperzero.one/">Flipper Zeros</a> to replay actual IR instructions to the device.</p>
<p>At this point, I realized I wasn’t about to break any news with my teardown. But I wasn’t doing this to break news. I was doing this to break into the device. Also, I didn’t find anyone doing this with a <code>Diamond</code> board device. I wasn’t invested enough to turn this into SBC or microcontroller project. I just wanted to do a minimal mod. So I pushed forward. And by that I mean I started poking around.</p>
<p>That small, bulbous, semiconductor in the middle of the board is the Infrared Receiver. It can detect infrared signal, like those emitted from TV remotes. The PixMob website says that this wristband is, “infrared activated” so this seemed like a great entrypoint. I have no idea what kind of IR receiver it is, so I looked up <a href="https://www.sparkfun.com/products/10266">the first one I could find on Sparkfun</a> and consulted the <a href="https://www.sparkfun.com/datasheets/Sensors/Infrared/tsop382.pdf">datasheet</a>. I learned a couple things:</p>
<ul>
<li>the three pins are <img src="https://latex.codecogs.com/png.latex?V_%7Bout%7D">, <img src="https://latex.codecogs.com/png.latex?GND">, and <img src="https://latex.codecogs.com/png.latex?V_%7Bcc%7D"></li>
<li>the <img src="https://latex.codecogs.com/png.latex?V_%7Bout%7D"> pin is active low, which means its voltage drops when activated</li>
</ul>
<p>There was no guarantee that my IR receiver also operated this way, but spoiler alert: it did!</p>
<p>I grabbed a jumper cable and touched one end to the IR’s <img src="https://latex.codecogs.com/png.latex?GND"> pin and ran the other side across one of the LED’s cathodes:</p>
<div class="quarto-video"><video id="video_shortcode_videojs_video1" class="video-js vjs-default-skin vjs-big-play-centered vjs-fluid" controls="" preload="auto" data-setup="{}" title=""><source src="06-led-cathode.mp4"></video></div>
<p>Great. So the batteries still have some juice (a lot of posts suggested this thing would be dead by the time I got it home) and I know the polarity and pinouts of the LEDs. And all the LEDs are in series, so that’s nice. I also learned that the LEDs are interrupted in their path to ground, not their path to positive voltage, which is extra nice since the IR <img src="https://latex.codecogs.com/png.latex?V_%7Bout%7D"> is active low. That means I can create a voltage drop from the LED’s anode to cathode by tying the cathode to <img src="https://latex.codecogs.com/png.latex?V_%7Bout%7D">, so when the IR receiver gets a signal and pulls <img src="https://latex.codecogs.com/png.latex?V_%7Bout%7D"> low, the LED should light up. And so I did! I grabbed the first remote I could find in my living room, and pointed it at the receiver:</p>
<div class="quarto-video"><video id="video_shortcode_videojs_video2" class="video-js vjs-default-skin vjs-big-play-centered vjs-fluid" controls="" preload="auto" data-setup="{}" title=""><source src="07-led-ir.mp4"></video></div>
<p>It blinks! You’ll notice that the LEDs aren’t as bright as when they had a direct jump to ground. That’s because <img src="https://latex.codecogs.com/png.latex?V_%7Bout%7D"> is still higher than <img src="https://latex.codecogs.com/png.latex?GND"> so the voltage difference is a lot smaller. Which means the light doesn’t get as bright.</p>
<p>That’s okay! This is a cheap crack and mod, so onward. After a bit more poking on the board, I found a surface-mounted resistor in series with the blue pinout from the LEDs. I soldered the IR signal jumper to that resistor, because we’re Team Blue.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://matthodges.com/posts/2024-08-28-cracking-modding-dnc-bracelet/08-soldered.jpeg" class="img-fluid figure-img"></p>
<figcaption>My crude soldering from the IR signal pin to the blue LED resistor</figcaption>
</figure>
</div>
<p>Not my best work. I’m better at through-hole soldering. But it works!</p>
<div class="quarto-video"><video id="video_shortcode_videojs_video3" class="video-js vjs-default-skin vjs-big-play-centered vjs-fluid" controls="" preload="auto" data-setup="{}" title=""><source src="09-soldered-ir.mp4"></video></div>
<p>I closed the case back up, shut myself in a windowless bathroom, turned off the lights,<sup>2</sup> and clicked my remote:</p>
<div class="quarto-video"><video id="video_shortcode_videojs_video4" class="video-js vjs-default-skin vjs-big-play-centered vjs-fluid" controls="" preload="auto" data-setup="{}" title=""><source src="10-dark-ir.mp4"></video></div>
<p>Now all I need to do is blast Beyoncé’s <em>“Freedom”</em> and I can relive the 2024 Democratic National Convention whenever I want.</p>
<p>I learned a lot!</p>




<div id="quarto-appendix" class="default"><section id="footnotes" class="footnotes footnotes-end-of-document"><h2 class="anchored quarto-appendix-heading">Footnotes</h2>

<ol>
<li id="fn1"><p>Neil Postman, I have forsaken thee.↩︎</p></li>
<li id="fn2"><p>Said, “Bloody Mary” three times↩︎</p></li>
</ol>
</section></div> ]]></description>
  <guid>https://matthodges.com/posts/2024-08-28-cracking-modding-dnc-bracelet/</guid>
  <pubDate>Wed, 28 Aug 2024 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2024-08-28-cracking-modding-dnc-bracelet/00-dnc-teardown.jpeg" medium="image" type="image/jpeg"/>
</item>
<item>
  <title>ActBlue Isn’t Selling Your Data</title>
  <dc:creator>Matt Hodges</dc:creator>
  <link>https://matthodges.com/posts/2024-08-25-actblue-isnt-selling-your-data/</link>
  <description><![CDATA[ 





<p>You constantly see posts among Democratic supporters that look something like this:</p>
<blockquote class="blockquote">
<p>I’m not donating via ActBlue anymore because they sell my data and I get spam.</p>
</blockquote>
<p>This misunderstanding not only damages trust in one of our most important fundraising tools but also obscures where the real issue lies. It is true that your data is being sold. And it is true that you’re getting spam. But ActBlue didn’t sell your data because ActBlue doesn’t own your data. This is written all over their website:</p>
<p><a href="https://help.actblue.com/hc/en-us/articles/19342854603287-Does-ActBlue-share-my-personal-information-including-email-address-and-phone-number">Does ActBlue share my personal information, including email address and phone number?</a></p>
<blockquote class="blockquote">
<p>We never sell or share your personal information with anyone beyond the group you gave to, and ActBlue will never text you.</p>
</blockquote>
<p><a href="https://help.actblue.com/hc/en-us/articles/19343256425367-Why-am-I-receiving-emails-and-or-texts-from-campaigns-and-organizations-after-chipping-in-on-ActBlue">Why am I receiving emails and/or texts from campaigns and organizations after chipping in on ActBlue?</a></p>
<blockquote class="blockquote">
<p>We never sell or share your personal information with anyone beyond the group you gave to, and ActBlue will never text you. Even if you get a link to donate on ActBlue in a text or email, it comes from a group that uses our tools, not us.</p>
</blockquote>
<p><a href="https://support.actblue.com/campaigns/faq/how-can-i-report-unwanted-communications-from-campaigns-or-organizations-or-a-potential-violation-of-actblues-terms-and-policies/">How can I report unwanted communications from campaigns or organizations or a potential violation of ActBlue’s terms and policies?</a></p>
<blockquote class="blockquote">
<p>When you donate using our platform, we do not sell your contact information or share it with third parties, except the group you donated to, or in certain circumstances, such as at your direction or when required by law.</p>
</blockquote>
<p>And in the <a href="https://secure.actblue.com/privacy">Privacy Policy</a>:</p>
<blockquote class="blockquote">
<p>ActBlue does not sell your contact information to third parties, and we don’t send solicitations, such as text messages or emails, on behalf of the individuals and organizations that use ActBlue for fundraising, including political committees and campaigns.</p>
</blockquote>
<p>As someone who has built campaign tech infrastructure for two Democratic presidential nominees, I can confirm first-hand that the only data that campaigns are getting directly from ActBlue are the records of their own donors. Misunderstanding its role — or the role of any donoation processor — undermines our collective efforts.</p>
<p>Other campaigns are still getting your data. When you donate to the campaign of Candidate X through ActBlue, the campaign of Candidate X owns that data. ActBlue provides the data to them, and they are free to use it as they see fit. The campaign of Candidate X is often (but not always!) selling it to other campaigns and electoral organizations.</p>
<p>You can see it happening in FEC filings. Here’s the first example I plucked: <a href="https://ballotpedia.org/John_Delaney_(Maryland)">Friends of John Delaney</a> was the committee name of John Delaney’s 2020 presidential campaign. And here’s <a href="https://docquery.fec.gov/cgi-bin/forms/C00508416/1223362/sb/ALL">Schedule B (itemized disbursements)</a> for one of their quarterly filings. Now smack <code>CMD+F</code> and search for <code>List Purchase</code>. You’ll spot voter data purchases from <a href="https://ballotpedia.org/Nate_Boulton">Boulton For Iowa</a>, <a href="https://ballotpedia.org/Thomas_Heckroth">Heckroth For Congress</a>, <a href="https://ballotpedia.org/Fred_Hubbell">Hubbel For Governor</a>, and <a href="https://ballotpedia.org/Pete_D%27Alessandro">Pete For Iowa</a> (no, not that Pete).</p>
<p>Campaigns share data for all sorts of reasons. Sometimes their campaign is over and they want to be a team player. Sometimes their campaign is over and they have outstanding debts and they need some cash to balance the books. Sometimes they move from a primary to a general election cycle and they are working closely with the affiliated federal committee or state party through coordinated committees or victory funds.</p>
<p>But again, that’s not ActBlue’s doing.</p>
<p>💡 <strong>Quick aside:</strong> ActBlue is reporting donations to the Federal Election Commission, as is required in their role as a conduit for campaign funds. There’s been concern about the public availability of this data, but it’s important to note that <a href="https://www.fec.gov/updates/sale-or-use-contributor-information/">it’s illegal for campaigns to use FEC data for solicitations</a>:</p>
<blockquote class="blockquote">
<p>To protect the privacy of individual contributors, the [Federal Election Campaign Act] prohibits the sale or use of any information about those donors, including their names and addresses, for the purpose of soliciting contributions or for commercial purposes. Commission regulations also prohibit the use of this information to solicit donations, including charitable donations.</p>
</blockquote>
<p>One of my favorite “fun facts” about US election data is salting. Committees may sprinkle throughout — or, “salt” — each report with up to ten fictitious contributor names:</p>
<blockquote class="blockquote">
<p>The committee itemizes each fictitious contribution on a Schedule A, providing a real address for each fictitious name (such as the address of a committee employee). The committee then adjusts its subtotals for itemized and unitemized contributions accordingly on the Detailed Summary Page. If a solicitation or commercial mailing is sent to one of the fictitious names, the committee will know that someone has used the names of contributors disclosed on its reports and may file a complaint with the FEC.</p>
</blockquote>
<p>The FEC even made a <code>#FECsplanation</code> (rhymes with explanation) video about it:</p>
<div class="quarto-video ratio ratio-16x9"><iframe data-external="1" src="https://www.youtube.com/embed/SSVGrpjQX4o" title="" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe></div>
<p>Kinda neat that the federal government advises campaigns to honeypot their data.</p>
<p>Anyway, yeah, your data is being traded around between campaigns. So when you donate to some campaign in New York it’s possible it’ll land in the hands of some campaign in Utah. And I agree it’s a pretty crummy to get spammed with emails and texts from candidates you’ve never heard of from communities you’ve never even visited. But that’s not ActBlue’s fault.</p>
<p>Voters and donors don’t actually care which step in the data chain of custody is to blame for the barrage of irrelevant messages. But those of us who work on Democratic tech infrastructure sure do care.</p>
<p>Campaigns actually don’t want to waste money, resources, and time on poorly-performing lists. With the right infrastructure, smart campaigns are actually quite good at identifying and reaching likely supporters with effective messages. The problem isn’t a lack of intention, but rather the inconsistency and uneven distribution of infrastructure across the Democratic ecosystem. ActBlue is a very robust piece of Democratic infrastructure. That’s not where our focus should be for this problem.</p>
<p>Better infrastructure isn’t just a matter of efficiency. It’s a necessity for maintaining trust and engagement with supporters. When campaigns have access to robust, well-maintained systems, they can target their outreach more precisely, reducing the overreliance on broad, untargeted lists that lead to spam and voter disengagement. And it isn’t just about minimizing annoyance; it’s about maximizing impact and ensuring that every dollar spent and every message sent has the greatest possible chance of reaching and resonating with the right people.</p>
<p>The challenge is keeping that infrastructure viable cycle-over-cycle, and extending its reach across the entire Democratic landscape. As we work towards building smarter campaigns, it’s vital that we advocate for, and invest in, the infrastructure that ensures our outreach is both effective and respectful. We have a lot more work to do on that front.</p>



 ]]></description>
  <guid>https://matthodges.com/posts/2024-08-25-actblue-isnt-selling-your-data/</guid>
  <pubDate>Sun, 25 Aug 2024 05:00:00 GMT</pubDate>
  <media:content url="https://matthodges.com/posts/2024-08-25-actblue-isnt-selling-your-data/fec-list-purchase.jpeg" medium="image" type="image/jpeg"/>
</item>
</channel>
</rss>
