<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>womack.io</title>
  
  
  <link href="/atom.xml" rel="self"/>
  
  <link href="http://womack.io/"/>
  <updated>2026-08-11T13:15:53.208Z</updated>
  <id>http://womack.io/</id>
  
  <author>
    <name>James J. Womack</name>
    
  </author>
  
  <generator uri="http://hexo.io/">Hexo</generator>
  
  <entry>
    <title>When Zero Means Zero: An LLM Safety Net That Ate Real Queries</title>
    <link href="http://womack.io/2026/08/11/When-Zero-Means-Zero/"/>
    <id>http://womack.io/2026/08/11/When-Zero-Means-Zero/</id>
    <published>2026-08-11T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>Ballpark Genius’s search bar takes plain English — “players with 30 or more home runs,” “who’son pace for 40 steals” — and an LLM turns it into a structured filter against Postgres. LLMsbeing LLMs, they occasionally hallucinate a filter nobody asked for: a stray<code>{ stat: &#39;plateAppearances&#39;, value: 0, direction: &#39;above&#39; }</code> tacked onto an otherwise correctparse. A threshold of “0 or more” is meaningless — every player has 0 or more plate appearances— and worse, it can null out an entire result set through the JSON-path filter it compiles into.So the search service has a safety net: strip any parsed threshold where the value is exactly 0and the direction is <code>at_least</code>/<code>above</code>/etc.</p><p>That net also ate every real query where a user typed “0” on purpose.</p><p><img src="/images/bpg-search-zero-or-more-triples.png" alt="Ballpark Genius search results for &quot;players with 0 or more three baggers,&quot; now correctly returning 18 sorted, ranked players"></p><p><em>“Players with 0 or more three baggers” — a real, syntactically valid query that used to return nothing sensible.</em></p><h2 id="The-parse-is-identical-either-way"><a href="#The-parse-is-identical-either-way" class="headerlink" title="The parse is identical either way"></a>The parse is identical either way</h2><p>Type “players with 0 or more three baggers” into the search bar, and the LLM parses it toexactly the same shape as the hallucinated placeholder:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">&#123; <span class="attr">"stat"</span>: <span class="string">"triples"</span>, <span class="attr">"direction"</span>: <span class="string">"at_least"</span>, <span class="attr">"value"</span>: <span class="number">0</span> &#125;</span><br></pre></td></tr></table></figure><p>There’s nothing in the parsed object that distinguishes “the model made this up” from “the userexplicitly asked for this.” The stripper had no way to tell them apart, so it deleted both,every time. “1 or more” and anything higher worked fine, because <code>value === 0</code> simply nevermatched — which is exactly why this sat unnoticed for a while. It’s the kind of bug that onlyshows up on the specific boundary condition nobody thinks to test, because a query for “0 ofsomething” <em>feels</em> like a null query even when it isn’t one.</p><h2 id="Losing-the-threshold-broke-more-than-the-threshold"><a href="#Losing-the-threshold-broke-more-than-the-threshold" class="headerlink" title="Losing the threshold broke more than the threshold"></a>Losing the threshold broke more than the threshold</h2><p>The consequence wasn’t just a missing filter — it silently broke sort order too. Downstream,<code>sortBy</code> gets inferred from whichever <code>*_min</code>/<code>*_max</code> filter key survived the parse. Strip thethreshold, and there’s no filter key left to infer a sort from, so the query falls back to nosort at all. A request for the top 20 by some stat quietly stopped showing the top 20 — itshowed whatever order the database happened to return, with no threshold and no ranking, and noerror anywhere in the chain to say so.</p><h2 id="The-fix-check-the-query-text-before-you-strip"><a href="#The-fix-check-the-query-text-before-you-strip" class="headerlink" title="The fix: check the query text before you strip"></a>The fix: check the query text before you strip</h2><p>The stripper’s problem was that it only had the parsed shape to go on. The fix gives it a secondsource of truth: before deleting a zero-valued threshold, re-run that stat’s own threshold regex— the same <code>THRESHOLD_PATTERNS</code> used to parse the query in the first place, defined in<code>stat-aliases.ts</code> — against the raw text the user actually typed. If “0” for that stat has areal textual basis in the query, the threshold survives. If it doesn’t — if there’s no explicitzero anywhere in the sentence — it gets stripped as the hallucination it almost certainly is.</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">buildFallbackToolArgs(<span class="string">"players with 0 or more three baggers"</span>, <span class="string">'player'</span>)</span><br><span class="line"><span class="comment">// -&gt; &#123; stat: 'triples', direction: 'at_least', value: 0 &#125;  (now survives)</span></span><br></pre></td></tr></table></figure><p>Same parsed shape, same value, but now the decision to keep or drop it is grounded in what theuser actually wrote instead of a blanket rule about the number zero.</p><h2 id="The-broader-shape-of-the-bug"><a href="#The-broader-shape-of-the-bug" class="headerlink" title="The broader shape of the bug"></a>The broader shape of the bug</h2><p>This is a pattern worth naming on its own: a safety net built to catch model noise, tunedagainst the failure case that motivated it, without checking whether it could also match alegitimate input that happens to look identical after parsing. The net wasn’t wrong to exist —LLM hallucination on placeholder values is real, and letting one through can null out a wholepage of results. It just needed a second signal beyond the parsed value to tell the two casesapart, and that signal — the original query text — was sitting right there the whole time,already used earlier in the same pipeline.</p><p><em>— James</em></p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Ballpark Genius’s search bar takes plain English — “players with 30 or more home runs,” “who’s
on pace for 40 steals” — and an LLM turns 
      
    
    </summary>
    
    
      <category term="ballpark-genius" scheme="http://womack.io/tags/ballpark-genius/"/>
    
      <category term="typescript" scheme="http://womack.io/tags/typescript/"/>
    
      <category term="debugging" scheme="http://womack.io/tags/debugging/"/>
    
      <category term="llm" scheme="http://womack.io/tags/llm/"/>
    
      <category term="semantic-search" scheme="http://womack.io/tags/semantic-search/"/>
    
  </entry>
  
  <entry>
    <title>The Bug That Reverted Every Trade: Three Silent Failures Hiding Behind Each Other</title>
    <link href="http://womack.io/2026/08/10/The-Bug-That-Reverted-Every-Trade/"/>
    <id>http://womack.io/2026/08/10/The-Bug-That-Reverted-Every-Trade/</id>
    <published>2026-08-10T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>Ballpark Genius is an MLB analytics site — player pages, projections, trade tracking, semanticsearch over the whole league. In one debugging session this week I chased a single symptom(“Adley Rutschman still shows as an Oriole five days after Baltimore traded him to Boston”)through three unrelated bugs, each of which perfectly hid the next one. None of them threw. Noneof them logged an error. Every one of them printed a success message while doing nothing, orthe wrong thing.</p><p>This is the write-up, in the order I actually found them, because the order matters: each fix<em>revealed</em> the next failure instead of resolving the symptom.</p><p><img src="/images/bpg-rutschman-traded-boston.png" alt="Adley Rutschman&#39;s Ballpark Genius player page, correctly showing &quot;Traded 8 days ago&quot; from Baltimore to Boston and his current team as Boston Red Sox"></p><p><em>Adley Rutschman’s page today — the state all three bugs conspired to prevent.</em></p><h2 id="Bug-1-the-cache-flush-that-flushed-nothing"><a href="#Bug-1-the-cache-flush-that-flushed-nothing" class="headerlink" title="Bug 1: the cache flush that flushed nothing"></a>Bug 1: the cache flush that flushed nothing</h2><p>The first theory was stale cache. I ran the project’s <code>clear-cache</code> script, expected a cleanslate, and got the same stale data back. Ran it again. Same result. The script printed<code>✓ cleared</code> every time.</p><p>The cause was an environment mismatch that had been there since the caching layer was built:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> CACHE_NAMESPACE = (process.env.CACHE_NAMESPACE || process.env.NODE_ENV || <span class="string">'dev'</span>).trim();</span><br></pre></td></tr></table></figure><p>The <strong>server</strong> runs with <code>NODE_ENV=development</code> and writes keys under <code>development:*</code>. The<strong>CLI script</strong>, run from a plain shell, has no <code>NODE_ENV</code> set at all — it resolves to <code>dev:</code>,issues a delete against <code>dev:*</code>, matches zero keys, and reports success anyway, because<code>delPattern</code> returned <code>void</code>. A zero-match delete was indistinguishable from a real one, at thetype level and in the printed output.</p><p>I only found it by going around the tool: <code>redis-cli EXISTS</code> against the <code>development:*</code> keysdirectly, before and after a “successful” flush. The keys were still there. That’s when Inoticed the namespace mismatch.</p><p>The fix made a no-op impossible to mistake for success:</p><ul><li><code>delPattern</code> now returns the number of keys it actually deleted, and every call site reportsthat count — <code>⚠️ 0 entries cleared</code> reads very differently than <code>✓ cleared</code>.</li><li>The tool now prints the namespace it’s about to operate on <em>and</em> what Redis actually holds,so a mismatch is visible before you even wait for the delete.</li><li>It detects the mismatch and tells you the fix:</li></ul><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Namespace: dev:  (Redis currently holds: development, test, today)</span><br><span class="line">⚠️  No keys exist under &quot;dev:&quot; — this flush will delete NOTHING.</span><br><span class="line">   Keys live under: development:, test:, today:</span><br><span class="line">   Re-run matching it, e.g.  NODE_ENV&#x3D;development npm run clear-cache -- ...</span><br></pre></td></tr></table></figure><p>This had been the standing explanation for a whole class of “why didn’t my fix take effect”reports across the project. The remedy people reached for first had never actually worked.</p><h2 id="Bug-2-the-importer-that-outvoted-the-trade"><a href="#Bug-2-the-importer-that-outvoted-the-trade" class="headerlink" title="Bug 2: the importer that outvoted the trade"></a>Bug 2: the importer that outvoted the trade</h2><p>With caching ruled out, the database itself said Rutschman was still Baltimore’s. A trade syncjob (<code>refresh-player-teams</code>) had correctly written his real club days earlier. Something waswriting it back.</p><p>The culprit was a stats importer — a job that pulls a player’s current-season batting orpitching splits and, as an incidental side effect, decided it should also own that player’steam assignment:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Update player's team if this is the most recent MLB team ...</span></span><br><span class="line"><span class="keyword">if</span> (player &amp;&amp; teamId &amp;&amp; teamName &amp;&amp; season === CURRENT_SEASON &amp;&amp; isMLBTeamId(teamId)) &#123;</span><br><span class="line">  <span class="keyword">await</span> prisma.player.update(&#123; where: &#123; id: playerId &#125;, data: &#123; teamId, teamName &#125; &#125;);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The comment claims “most recent.” There is no recency check anywhere in that block — it simplyoverwrites with whatever split MLB’s API happens to return next, in whatever order the APIhappens to return them. For a player traded mid-season, the old club is often the <em>only</em> onewith any stat rows at all: Rutschman had two Baltimore rows for 2026 and zero for Boston,because he went straight to Boston’s injured list without playing a game there yet. So everytime his stats got re-synced — including just from a page view triggering a background refresh —his team flipped back to the club that had traded him away, silently, with a normal-looking<code>updated_at</code> timestamp as the only trace.</p><p>The fix was to make that write fill-only: populate a missing team, never overwrite an existingone. A stats importer has no business deciding a player’s current club — that’s what the tradesync and MLB’s own <code>parentOrgId</code> field are for. The importer can still help a player that thoseother syncs haven’t reached yet, which is the entire reason the write existed in the firstplace; it just can’t outrank them anymore.</p><p>Fixing this repaired 51 players who’d been reverted by exactly this path.</p><h2 id="Bug-3-the-schema-that-quietly-dropped-the-answer"><a href="#Bug-3-the-schema-that-quietly-dropped-the-answer" class="headerlink" title="Bug 3: the schema that quietly dropped the answer"></a>Bug 3: the schema that quietly dropped the answer</h2><p>There was a third bug feeding into the same symptom, found a few days earlier in the same chase:<code>fix-milb-teams</code>, a job meant to correct minor-league team assignments, saw Rutschman parked ona non-MLB affiliate, decided that looked broken, and “fixed” it by picking whichever MLB clubhe’d played the <em>most</em> games for historically — which, for a player traded five days ago onto aninjured list, is always the old team. Volume-based inference is exactly backwards for someonewho just moved.</p><p>MLB’s API does hand back the correct answer directly, as <code>currentTeam.parentOrgId</code>. But our Zodschema for the player payload didn’t declare that field, and Zod drops unknown keys by default —so the correct value arrived from MLB and was silently discarded before any of our code ever sawit. The first draft of this fix read <code>parentOrgId</code>, got <code>undefined</code>, and would have quietlyfallen back to the same broken most-played-games logic while looking like it had been fixed. Ionly caught that by checking the <em>parsed</em> output against the raw response, not trusting thatadding a schema field was enough on its own.</p><p>With <code>parentOrgId</code> declared and read, <code>fix-milb-teams</code> now asks MLB directly instead ofinferring from history. Repair applied to the 328 players whose team contradicted their mostrecent trade: 52 genuinely wrong, corrected; 276 were false positives from the detection queryitself — players like Verlander and Scherzer, whose team correctly differs from an <em>old</em> tradebecause they’ve since moved again on their own. The script checks with MLB before touchinganyone, so only the 52 real mismatches changed.</p><h2 id="What-all-three-had-in-common"><a href="#What-all-three-had-in-common" class="headerlink" title="What all three had in common"></a>What all three had in common</h2><p>None of these bugs were exotic. A namespace typo. A missing recency check with a comment thatlied about having one. A schema that silently dropped a field. Individually they’re the kind ofthing you’d catch in five minutes. What made them dangerous together is that each one’s silencelooked exactly like the previous fix working: flush the cache (nothing happens, but it <em>says</em>it worked), so you conclude the data itself must be wrong; fix the importer (the revert stopsfor a while, until the next stats sync), so you conclude the trade sync must be flaky; add theschema field (still wrong, because the value was never read), so you conclude the API itselfmust be inconsistent.</p><p>The lesson I keep relearning on this project: a tool that can silently do nothing is worse thana tool that fails loudly, because it burns your best hypothesis on every pass. The actual fix,each time, was smaller than the investigation it took to find it.</p><p><em>— James</em></p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Ballpark Genius is an MLB analytics site — player pages, projections, trade tracking, semantic
search over the whole league. In one debug
      
    
    </summary>
    
    
      <category term="ballpark-genius" scheme="http://womack.io/tags/ballpark-genius/"/>
    
      <category term="typescript" scheme="http://womack.io/tags/typescript/"/>
    
      <category term="postgres" scheme="http://womack.io/tags/postgres/"/>
    
      <category term="redis" scheme="http://womack.io/tags/redis/"/>
    
      <category term="debugging" scheme="http://womack.io/tags/debugging/"/>
    
      <category term="data-integrity" scheme="http://womack.io/tags/data-integrity/"/>
    
  </entry>
  
  <entry>
    <title>Building Blockbuster Trades: Ranking a Decade of MLB Deals Without Making Up a Score</title>
    <link href="http://womack.io/2026/08/05/Building-Blockbuster-Trades/"/>
    <id>http://womack.io/2026/08/05/Building-Blockbuster-Trades/</id>
    <published>2026-08-05T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>Ballpark Genius tracks MLB trades — who went where, and how big a deal actually was. “How big”is the interesting part: a two-month rental reliever and a controllable six-year ace are not thesame asset, and a naive trade feed treats them identically. This is the story of building thatranking end to end — from a primary-key bug that was silently collapsing multi-player trades, toa scoring function whose weights came from grading 201 real trades by hand rather than pickingnumbers that felt right.</p><p><img src="/images/bpg-blockbuster-trades.png" alt="Ballpark Genius trade board, headlined by the Tarik Skubal / Adley Rutschman deals, showing WAR, awards, and control-year estimates for every player involved"></p><p><em>The trade board today — the Skubal and Rutschman deals are the two real trades this whole build kept getting checked against.</em></p><h2 id="First-the-data-had-to-stop-lying-about-who-was-in-a-trade"><a href="#First-the-data-had-to-stop-lying-about-who-was-in-a-trade" class="headerlink" title="First, the data had to stop lying about who was in a trade"></a>First, the data had to stop lying about who was in a trade</h2><p>Before any ranking could matter, the <code>transactions</code> table had a structural bug: <code>Transaction.id</code>was declared as the primary key, but MLB’s Stats API <code>id</code> field is a <strong>deal identifier shared byevery player in that trade</strong>, not a per-person id. A four-player trade upserts four rows that allshare one <code>id</code> — so each row overwrote the last, and only one player of every multi-player dealever survived in the database. The natural key needed to be <code>(dealId, personId)</code>, not <code>id</code> alone.Re-keying that was the precondition for everything that followed: you can’t rank a trade’splayers if the database only remembers one of them.</p><p>With the key fixed, I ran a one-off backfill pulling MLB’s transaction history in monthly windowsfrom January 2016 through the present. The first full run silently dropped 56 of 128 windows.The cause: the API client wrapped its schema validation in a try/catch that swallowed any Zoderror and returned an empty array — so <em>one</em> malformed record failed validation for the entiremonth, and the caller had no way to distinguish “zero trades this month” from “parsing blew upand we got nothing.” The captured Zod errors pointed at the real cause: some historical records(pre-2020 releases, minor-league moves) omit a <code>name</code> field on the traded-to or traded-from teamthat the schema had marked required. Making that field optional, and adding a strict variant ofthe fetch that rethrows instead of swallowing, turned “quietly wrong” into “loudly correct”: there-run processed all 132 windows with zero failures, landing 513,690 transactions including 5,883trade records going back a decade.</p><h2 id="Estimating-control-years-without-contract-data"><a href="#Estimating-control-years-without-contract-data" class="headerlink" title="Estimating control years without contract data"></a>Estimating control years without contract data</h2><p>A trade’s weight depends heavily on how many years of team control each player still has left —but this project has no contract or service-time database yet. So I built an estimator thatworks from what’s actually available: MLB debut date, bucketed into <code>EXPIRING</code> / <code>SHORT</code> /<code>MEDIUM</code> / <code>LONG</code> control windows based on years of service.</p><p>Before shipping it, I ran the validation the spec called for: hand-check the 25 active playerswith the highest career WAR — the worst case for a debut-date-only heuristic, since long-tenuredstars are exactly who tends to sign extensions the heuristic can’t see — against their realcontract status via web search. <strong>15 of 25 (60%) were false positives.</strong> The heuristic wascalling players “expiring” who were actually locked up for years.</p><p>60% is well above the 25% threshold the spec had set as an acceptable error rate for a roughestimate, so the fix wasn’t a tuning pass — it was changing what the UI <em>claims</em>. The bucket’sdisplay label went from the confident-sounding <code>RENTAL</code> to the honest <code>CONTRACT UNKNOWN</code>, andevery control estimate in the product now carries a persistent <code>confidence: &#39;estimate&#39;</code> flag.The estimator wasn’t good enough to assert a fact, so the product stopped asserting one.</p><h2 id="Scoring-“blockbuster”-against-real-trades-not-intuition"><a href="#Scoring-“blockbuster”-against-real-trades-not-intuition" class="headerlink" title="Scoring “blockbuster” against real trades, not intuition"></a>Scoring “blockbuster” against real trades, not intuition</h2><p>The ranking itself — <code>scoreBlockbuster()</code> — blends six weighted signals: star power, awards,headcount, estimated control years, deadline proximity, and whether the trade crosses leagues.The weights aren’t guesses. They’re derived by scoring all 201 real MLB trades from the 2026season against three different candidate models — star-dominant, package-weighted, and aWAR-rate model — and picking the one whose output actually matched a human sense of “how big wasthis trade,” which meant falsifying and discarding the first two before landing on the currentone.</p><p>Two bugs surfaced in the process worth calling out because both hid behind a passing test suite:</p><p><strong>Award matching was silently wrong.</strong> The scorer needs to recognize award types like Cy Youngand MVP, but the real <code>award_type</code> values in the database are granular — <code>WS_MVP</code>, <code>ALCS_MVP</code>,<code>NLCS_MVP</code>, <code>ALL_STAR_MVP</code> all contain the substring <code>MVP</code>. A naive substring match would scoreevery one of those as a full regular-season MVP. Worse, the test fixture for the headline Skubaltrade used <code>awardType: &#39;AL_CY_YOUNG&#39;</code> — a value that doesn’t exist anywhere in the real data (CyYoung is stored league-neutral as <code>CY_YOUNG</code>, with league in a separate column). That typo meantthe fixture silently fell through to the generic unknown-award fallback weight instead ofexercising the actual Cy Young weight — so the single highest-value award in the whole config hadzero real test coverage, and the test suite was green the entire time. The fix was exact-keymatching plus a corrected fixture pulling Skubal’s <em>real</em> award rows straight from the database.</p><p><strong>Truncation before filtering.</strong> A related bug in the trade-detail page: <code>getPlayerTradeDeals</code>scanned a date range and capped it with <code>limit: 500</code> <em>before</em> filtering to the player beingviewed. Across a full career that window holds thousands of deals, so for a player with enoughtransaction history, their trade could get cut by the limit before the filter ever ran. The fixwas to select by <code>dealId</code> directly instead of truncating a superset and hoping the targetsurvived. This is the same shape of bug as the ranking’s earlier limit-then-filter mistake in adifferent query — worth naming as a pattern: never truncate a set you’re about to filter.</p><h2 id="What-actually-shows-up-in-the-product"><a href="#What-actually-shows-up-in-the-product" class="headerlink" title="What actually shows up in the product"></a>What actually shows up in the product</h2><p>The result is what’s on the trade board now: every deal shows real WAR, real awards, and acontrol-year estimate that’s honest about being an estimate — not a synthetic “trade grade”dressed up to look more certain than the underlying data supports. The Skubal/Dodgers-Tigers dealthat ran through every stage of this build as the reference case — four players, real awards, aCy Young winner headlining one side — is the top-ranked trade of the season, which is also justtrue.</p><p><em>— James</em></p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Ballpark Genius tracks MLB trades — who went where, and how big a deal actually was. “How big”
is the interesting part: a two-month renta
      
    
    </summary>
    
    
      <category term="ballpark-genius" scheme="http://womack.io/tags/ballpark-genius/"/>
    
      <category term="typescript" scheme="http://womack.io/tags/typescript/"/>
    
      <category term="postgres" scheme="http://womack.io/tags/postgres/"/>
    
      <category term="data-engineering" scheme="http://womack.io/tags/data-engineering/"/>
    
      <category term="sports-data" scheme="http://womack.io/tags/sports-data/"/>
    
  </entry>
  
  <entry>
    <title>This Blog Vendors Its Own Static Site Generator Now</title>
    <link href="http://womack.io/2026/08/03/This-Blog-Vendors-Its-Own-Static-Site-Generator-Now/"/>
    <id>http://womack.io/2026/08/03/This-Blog-Vendors-Its-Own-Static-Site-Generator-Now/</id>
    <published>2026-08-03T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>womack.io runs on Hexo and a theme called apollo, both from around 2019. <code>package-lock.json</code> was gitignored this whole time, so every fresh install re-resolved every <code>hexo-*</code> plugin’s <code>^</code> range against whatever npm currently had. That’s how this exact site went down before: some plugin shipped a change apollo never agreed to support, and there was nothing pinning the tree to stop it.</p><p>The theme is written against Hexo 4.x’s generator and renderer APIs specifically. Upstream Hexo has moved past that, so floating a <code>^4.2.1</code> dependency was never going to be safe long-term, there was no version of “just upgrade” that didn’t mean rewriting the theme. So I vendored it instead: Hexo 4.2.1 lives at <code>packages/hexo</code> now, published locally as <code>@jameswomack/hexo</code>, installed via <code>&quot;hexo&quot;: &quot;file:packages/hexo&quot;</code> so it lands in <code>node_modules/hexo</code> right where <code>hexo-cli</code> expects it. Every <code>hexo-*</code> plugin version is pinned exact, no carets, and <code>package-lock.json</code> is finally tracked. The tree can’t drift out from under the theme again because nothing in it is allowed to move on its own anymore.</p><p>Before trusting it I ran <code>hexo generate</code> under Node 20, 22, and 24 and diffed the output. Byte-identical, except <code>sitemap.xml</code>‘s entry order, which turned out to be non-deterministic tie-breaking on same-timestamp posts, not a real difference. <code>.nvmrc</code> moved to 24.7.0 for Vercel the same day.</p><p><img src="/images/womackio-site-live.png" alt="womack.io, running on the now-vendored Hexo fork"></p><p>Fifteen minutes after that landed, <code>npm audit</code> flagged <code>send</code> under 0.19.0 for a template injection XSS, GHSA-m6fv-jmcg-4jfg, reachable through <code>hexo-server</code> and <code>hexo-browsersync</code>, both of which are dev-server-only and both of which haven’t been touched upstream in years. No fixed version existed inside their declared ranges. Forced <code>send</code> to 0.19.2 and <code>serve-static</code> to 1.16.3 via <code>package.json</code> overrides. Tried jumping <code>serve-static</code> straight to 2.x first, that broke <code>hexo-browsersync</code>‘s plugin loading outright, its wrapper assumes the 1.x API, so back to the patched 1.x line instead.</p><p>Fitting, in a way, to be doing this kind of plumbing work on the same blog that’s currently telling you about it. Still finding these, four commits later it was a footer “Next »” link rendering as literal escaped text because of the same paginator helper’s default escaping. Old software has old corners.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;womack.io runs on Hexo and a theme called apollo, both from around 2019. &lt;code&gt;package-lock.json&lt;/code&gt; was gitignored this whole time, s
      
    
    </summary>
    
    
      <category term="hexo, npm, node, security, meta, womack-io" scheme="http://womack.io/tags/hexo-npm-node-security-meta-womack-io/"/>
    
  </entry>
  
  <entry>
    <title>Building the Womack Audio Suite: note-aware resonance, a fisheye EQ, and a bus that almost wasn&#39;t</title>
    <link href="http://womack.io/2026/07/30/Building-the-Womack-Audio-Suite/"/>
    <id>http://womack.io/2026/07/30/Building-the-Womack-Audio-Suite/</id>
    <published>2026-07-30T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>I set out to build a small family of macOS audio plugins that feel like they were made bythe same hand and <em>know about each other</em>: a note-aware resonant filter, a fuzz/vibe/delaypedalboard, a lyric teleprompter — and, underneath them, a shared “bus” so instances cantalk. Along the way I fought a filter into warmth without letting it scream, drew an EQ curvethrough a fisheye lens, threw out a chord detector that couldn’t name basic chords, and spent anafternoon learning why a plugin can work perfectly in Standalone and be stone dead in Logic.</p><p>This is the engineering story — the musical decisions and the programming ones — with thewar stories left in.</p><p>The suite today:</p><ul><li><strong>Womack FX</strong> — a fuzz → univibe → tape-delay pedalboard (<code>aufx</code>).</li><li><strong>Womack Resonote</strong> — a note-quantized resonant filter/EQ, single- and multi-band (<code>aumf</code>).</li><li><strong>Womack Lyriqueue</strong> — session-stored lyrics with a playhead-following teleprompter (<code>aufx</code>).</li><li><strong>WomackBus</strong> — a shared, in-process message bus the plugins register on.</li></ul><p>Stack throughout: <strong>JUCE 8.0.12</strong>, <strong>C++20</strong>, <strong>CMake</strong> (Xcode generator), building <strong>AU +VST3 + Standalone</strong>, unit-tested with JUCE’s console <code>UnitTest</code> runner, signed and notarizedfor distribution.</p><p><img src="/images/diagram-suite-architecture.svg" alt="Suite architecture: Womack FX, two Resonote instances, and Lyriqueue all registering on one shared WomackBus dylib inside a single DAW process"></p><hr><h2 id="Part-1-—-Resonote-making-a-filter-that-knows-about-notes"><a href="#Part-1-—-Resonote-making-a-filter-that-knows-about-notes" class="headerlink" title="Part 1 — Resonote: making a filter that knows about notes"></a>Part 1 — Resonote: making a filter that knows about notes</h2><p>Most EQs think in raw Hz. Musicians think in notes. <strong>Resonote</strong> closes that gap: its cutoffcan snap to note frequencies, optionally constrained to a key, with a live <em>cents-from-nearest</em>readout so you always know how in-tune the resonance is.</p><h3 id="Note-math-you-can-trust"><a href="#Note-math-you-can-trust" class="headerlink" title="Note math you can trust"></a>Note math you can trust</h3><p>The pitch math lives in a small, pure, unit-tested header (<code>NoteFrequency.h</code>): MIDI↔Hz,<code>nearestMidi</code>, <code>noteName</code>, <code>centsFromNearest</code>, and scale-aware snapping for chromatic, major,and minor. Keeping it pure meant I could test the tricky parts directly instead of poking ata filter and squinting at a spectrum.</p><p>One bug from this corner is worth calling out because it’s so ordinary: the cents readout onceshowed <code>+9.61706e-05 c</code>. <code>juce::String(cents, 0)</code> doesn’t <em>round</em> — it formats — so a whiskerabove zero printed in scientific notation. The fix was a <code>juce::roundToInt</code> before display.Tiny, but it’s the difference between “precise instrument” and “toy.”</p><h3 id="The-warmth-one-SVF-with-a-tanh-in-the-feedback"><a href="#The-warmth-one-SVF-with-a-tanh-in-the-feedback" class="headerlink" title="The warmth: one SVF with a tanh in the feedback"></a>The warmth: one SVF with a tanh in the feedback</h3><p>The heart of Resonote is a single <strong>zero-delay-feedback TPT state-variable filter</strong>(<code>ResonantSVF</code>). It runs in Bell, Low-Pass, or High-Pass mode. The only “analog” trick — andit’s deliberately the <em>only</em> one — is a <code>tanh</code> nonlinearity in the resonance feedback path.</p><p><img src="/images/diagram-svf-signal-path.svg" alt="Resonote SVF signal path: input summed through two TPT integrators to bandpass and lowpass, with a tanh of the bandpass fed back as negative resonance"></p><p>That <code>tanh</code> does two jobs. It adds a gentle, level-dependent saturation as resonance climbs —the “warmth” — and it <em>self-limits</em>, so the filter stays stable and never tips intoself-oscillation. That let me be aggressive with the resonance range. <code>resonanceToQ</code> maps anormalized <code>0..1</code> knob to <strong>Q ≈ 0.5 … 75</strong>:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 0..1 -&gt; Q 0.5..~75, musically weighted toward the top of the range.</span></span><br><span class="line"><span class="function"><span class="keyword">float</span> <span class="title">resonanceToQ</span> <span class="params">(<span class="keyword">float</span> r)</span> <span class="keyword">noexcept</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line">    r = juce::jlimit (<span class="number">0.0f</span>, <span class="number">1.0f</span>, r);</span><br><span class="line">    <span class="keyword">return</span> <span class="number">0.5f</span> + <span class="built_in">std</span>::<span class="built_in">pow</span> (r, <span class="number">3.0f</span>) * <span class="number">74.5f</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Cranked, it <em>sings</em> on a note without ever howling — because the <code>tanh</code> in the loop eats therunaway energy that self-oscillation would need.</p><h3 id="The-fisheye-response-curve"><a href="#The-fisheye-response-curve" class="headerlink" title="The fisheye response curve"></a>The fisheye response curve</h3><p>An EQ curve on a log-frequency axis wastes its most interesting real estate: the region rightaround where you’re working is cramped. So Resonote’s response display bends space. It drawseverything through a <strong>horizontal fisheye</strong> — an <code>erf</code> “bump” that magnifies the area aroundthe current cutoff and compresses the far edges — plus a subtle <strong>convex vertical bow</strong> for atactile “glass” feel.</p><p><img src="/images/fisheye-response-curve.svg" alt="Schematic of the fisheye response curve"></p><p><em>Schematic illustration (not a screenshot).</em></p><p><img src="/images/resonote-single-band.png" alt="Womack Resonote single-band in Logic Pro — Low-Pass, cutoff snapped to A3, the fisheye response curve, and the live note/cents readout"></p><p><em>The real Resonote UI in Logic Pro — single band, cutoff snapped to A3.</em></p><p>The horizontal mapping from a normalized log-frequency position <code>u ∈ [0,1]</code> to a pixel <code>x</code> is:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// erf "bump" centred on u0 (the cutoff), pinned at the frame edges.</span></span><br><span class="line"><span class="function"><span class="keyword">float</span> <span class="title">uToX</span> <span class="params">(<span class="keyword">float</span> u)</span> <span class="keyword">const</span> <span class="keyword">noexcept</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line">    <span class="keyword">const</span> <span class="keyword">float</span> bump = lensStrength * (<span class="built_in">std</span>::erf ((u - lensU0) / lensWidth) - lensBumpAt0);</span><br><span class="line">    <span class="keyword">return</span> lastArea.getX() + ((u + bump) / lensDenom) * lastArea.getWidth();</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Because <code>uToX</code> is <strong>monotonic increasing</strong> in <code>u</code>, it has a well-defined inverse — and I needthat inverse constantly: to hit-test the mouse against the curve, and to let you <em>drag thecrossover handles</em> between bands and have them land exactly on the boundary they control.Rather than derive a closed-form inverse of an <code>erf</code>, I just <strong>bisect</strong>:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// uToX is monotonic in u, so bisect for its inverse. ~30 iterations over [0,1]</span></span><br><span class="line"><span class="comment">// is well below sub-pixel at any realistic width.</span></span><br><span class="line"><span class="function"><span class="keyword">float</span> <span class="title">xToU</span> <span class="params">(<span class="keyword">float</span> x)</span> <span class="keyword">const</span> <span class="keyword">noexcept</span></span></span><br><span class="line"><span class="function"></span>&#123;</span><br><span class="line">    <span class="keyword">float</span> lo = <span class="number">0.0f</span>, hi = <span class="number">1.0f</span>;</span><br><span class="line">    <span class="keyword">for</span> (<span class="keyword">int</span> i = <span class="number">0</span>; i &lt; <span class="number">30</span>; ++i)</span><br><span class="line">    &#123;</span><br><span class="line">        <span class="keyword">const</span> <span class="keyword">float</span> mid = <span class="number">0.5f</span> * (lo + hi);</span><br><span class="line">        <span class="keyword">if</span> (uToX (mid) &lt; x) lo = mid; <span class="keyword">else</span> hi = mid;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> <span class="number">0.5f</span> * (lo + hi);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Thirty iterations is nothing per frame, it’s trivially correct, and it means the <em>exact same</em>warp is used for drawing and for interaction. The handles never drift off the slabs they sit on.</p><h3 id="Multiband-up-to-four-bells-that-don’t-step-on-each-other"><a href="#Multiband-up-to-four-bells-that-don’t-step-on-each-other" class="headerlink" title="Multiband: up to four bells that don’t step on each other"></a>Multiband: up to four bells that don’t step on each other</h3><p>Resonote grew from one band to <strong>up to four</strong>, each a colour-coded bell with its ownfrequency, resonance, and gain, and each confined to a <strong>mutually-exclusive range at least anoctave wide</strong>. A <code>+ / –</code> LED changes the band count; adding a band auto-splits the spectrum,after which the <strong>crossovers are draggable</strong> (through that same fisheye inverse). Each band alsogets <strong>tempo-synced resonance modulation</strong> — an LFO with a per-band depth and a host-syncedrate, plus a global shape.</p><p><img src="/images/multiband-layout.svg" alt="Schematic of the multiband layout"></p><p><em>Schematic illustration (not a screenshot).</em></p><p><img src="/images/resonote-multiband.png" alt="Womack Resonote multiband in Logic Pro — Band 3 selected, Suite Spectrum on showing another instance&#39;s frequency as a ghost marker, and the chord readout"></p><p><em>Multiband Resonote in Logic with Suite Spectrum on — the greyed “Organ Donations” ghost marker is another instance’s frequency, and the readout names the combined chord.</em></p><h3 id="The-chord-detector-I-had-to-rip-out-and-rewrite"><a href="#The-chord-detector-I-had-to-rip-out-and-rewrite" class="headerlink" title="The chord detector I had to rip out and rewrite"></a>The chord detector I had to rip out and rewrite</h3><p>The multiband version reads out the chord formed by the active bands. My first attempt washand-rolled, and it was simply <em>wrong</em>: for the notes of an F minor 7 itdisplayed <strong><code>F +3 +5 +7</code></strong> — printing raw semitone offsets as if they were chorddegrees. For a music app, that’s just not good enough.</p><p>There is no clean drop-in C++ chord library, so I ported the algorithm that the JavaScriptworld trusts — <strong>tonal.js</strong>-style detection: normalize the pitch classes, then <strong>rotate throughevery note as a candidate root</strong>, match the resulting interval set against known chordqualities, and prefer the interpretation with the best root; fall back to <strong>slash-chordinversions</strong> (<code>C/E</code>) and, only if nothing matches, to an honest <strong>note-name list</strong> rather thanfake degrees. Augmented-7 spells as <code>aug7</code>, never <code>+7</code>, precisely because a <code>+</code> had been thesymptom of the old bug.</p><p>The lesson wasn’t “chords are hard” (they are). It was: when your domain has a correct answer,either compute the correct answer or say you can’t — don’t invent notation that <em>looks</em> right.</p><hr><h2 id="Part-2-—-The-Logic-bug-class-that-bit-us-three-times"><a href="#Part-2-—-The-Logic-bug-class-that-bit-us-three-times" class="headerlink" title="Part 2 — The Logic bug class that bit us three times"></a>Part 2 — The Logic bug class that bit us three times</h2><p>Here’s the war story every plugin dev should internalize.</p><p>Resonote’s visualizer, note readout, and chord display all read their values from small<code>std::atomic</code> snapshots that were written inside <code>processBlock</code>. In the <strong>Standalone</strong> app thatwas fine — the audio device runs continuously, so <code>processBlock</code> fires forever and the snapshotsare always fresh. Ship the same binary into <strong>Logic</strong>, stop the transport, turn a knob… andnothing moves. The curve freezes. The chord goes stale. It looked broken, and only in the DAW.</p><p><img src="/images/diagram-stale-snapshot-flow.svg" alt="Stale-snapshot bug: the UI reading process-time snapshot atomics goes live in Standalone but stale in Logic when the transport is stopped; the fix reads the parameter directly"></p><p>The root cause: <strong>Logic doesn’t call <code>processBlock</code> when the transport is stopped</strong> (and onlydoes at all when audio flows through the track). Reading UI values from process-time snapshotscouples your interface to the transport state. The fix is a one-liner in spirit — <em>read theparameters directly on the message thread, not the process-time mirror</em> — and I routed everyUI getter through a single <code>effectiveBandFreqHz()</code> helper so the audio path and the display cannever disagree.</p><p>It bit three times: first the <strong>band count</strong> stuck at 1, then the <strong>whole visualizer + chordreadout</strong> went inert, then <strong>Lyriqueue’s transport readout</strong> froze for the same reason.“Works in Standalone, dead in Logic” is now a smell I recognize instantly.</p><h3 id="The-sibling-gotcha-change-gestures"><a href="#The-sibling-gotcha-change-gestures" class="headerlink" title="The sibling gotcha: change gestures"></a>The sibling gotcha: change gestures</h3><p>While chasing the band-count issue I hit its cousin. Writing a parameter from code with<code>setValueNotifyingHost</code> <strong>works in Standalone and silently reverts in Logic</strong> — unless youbracket it in a gesture:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">param-&gt;beginChangeGesture();</span><br><span class="line">param-&gt;setValueNotifyingHost (newValue);</span><br><span class="line">param-&gt;endChangeGesture();</span><br></pre></td></tr></table></figure><p>Logic treats an un-gestured programmatic write as noise and rolls it back. Standalone doesn’tcare. Same shape of bug, same “only in the DAW” signature.</p><blockquote><p>The meta-lesson: <strong>Standalone is a liar of omission.</strong> It’s a great fast loop, but the DAW isthe only ground truth. auval on every build, then quit-and-reopen Logic (it caches AU binariesin memory) before you trust anything.</p></blockquote><hr><h2 id="Part-3-—-Lyriqueue-lyrics-that-follow-the-playhead"><a href="#Part-3-—-Lyriqueue-lyrics-that-follow-the-playhead" class="headerlink" title="Part 3 — Lyriqueue: lyrics that follow the playhead"></a>Part 3 — Lyriqueue: lyrics that follow the playhead</h2><p><strong>Lyriqueue</strong> stores lyrics in the session and shows a teleprompter that follows the DAW’splayhead, cue by cue, in musical time. Cues are stored canonically as <strong>absolute PPQ</strong>(quarter-notes from song start) and rendered as <strong>Bar.Beat.Ticks</strong> using the host’s timesignature. The active line is simply “the cued line with the greatest cue ≤ now.”</p><p>An early version showed impossible Bar.Beat.Ticks values; the fix was to do all arithmetic inPPQ and convert once at the edges, so beats and ticks always roll over musically.</p><p>It’s an <code>aufx</code> audio effect that doesn’t touch the audio — which produced a genuinely confusingsupport moment: on a silent track, <strong>Logic never processes it</strong>, so the playhead never arrivesand following looks broken. The right move wasn’t code gymnastics; it was <strong>discoverability</strong> —a live transport readout plus a “waiting for transport” hint that appears only when the host hasnever processed the track (detected with a <code>processBlock</code> counter that never advances), andlatches off the instant a block arrives so it never nags on a working track.</p><p><img src="/images/lyriqueue-teleprompter.png" alt="Womack Lyriqueue teleprompter in Logic Pro — lyrics with the active line brightest and neighbours fading, the Bar.Beat.Ticks readout, and the amber transport hint"></p><p><em>Lyriqueue’s teleprompter in Logic — the amber line at the bottom is exactly that “waiting for transport” hint.</em></p><hr><h2 id="Part-4-—-WomackBus-the-shared-bus-that-almost-wasn’t"><a href="#Part-4-—-WomackBus-the-shared-bus-that-almost-wasn’t" class="headerlink" title="Part 4 — WomackBus: the shared bus that almost wasn’t"></a>Part 4 — WomackBus: the shared bus that almost wasn’t</h2><p>This is the part I’m proudest of, because it looked done, passed its tests, and was quietlywrong.</p><p>I wanted the plugins to be a <em>suite</em> — to share musical context in one process. So I built<strong>WomackBus</strong>: a process-wide singleton with a client registry, a small blackboard, and changelisteners; each plugin owns a RAII <code>WomackBusClient</code> that registers on construction andunregisters on destruction, and shows a tiny “Womack ×N” presence badge.</p><p>I built <code>WomackCommon</code> as a <strong>static library</strong>, linked it into each plugin, saw the badge show<code>×1</code>, shipped it. The unit tests were green. The badge was… always <code>×1</code> across <em>different</em>plugins.</p><h3 id="Why-static-linking-silently-broke-it"><a href="#Why-static-linking-silently-broke-it" class="headerlink" title="Why static linking silently broke it"></a>Why static linking silently broke it</h3><p>A function-local-static singleton (<code>WomackBus::get()</code>) linked <strong>statically into two separateplugin bundles</strong> gets <strong>duplicated</strong> — each bundle carries its own copy. On macOS with thedefault two-level namespace, strong duplicate symbols are <em>not</em> coalesced across separatelyloaded bundles. So Resonote had one bus, Lyriqueue had another, and they never saw each other.Two instances of the <em>same</em> plugin shared (same image); two <em>different</em> plugins did not.</p><p><code>nm</code> shows it plainly — every plugin binary <em>defined</em> the symbol (<code>T</code>), instead of importing it:</p><figure class="highlight console"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">$</span><span class="bash"> nm <span class="string">"Womack Resonote"</span> | grep WomackBus3getEv</span></span><br><span class="line">0000000000009ea0 (__TEXT,__text) external __ZN9WomackBus3getEv     # T: its OWN copy</span><br><span class="line"><span class="meta">$</span><span class="bash"> nm <span class="string">"Womack Lyriqueue"</span> | grep WomackBus3getEv</span></span><br><span class="line">0000000000009094 (__TEXT,__text) external __ZN9WomackBus3getEv     # T: a DIFFERENT own copy</span><br></pre></td></tr></table></figure><p><img src="/images/diagram-static-vs-shared-linking.svg" alt="Static lib gives each plugin bundle its own WomackBus copy (two buses); one shared dylib gives every plugin an imported symbol and a single bus per process"></p><h3 id="The-fix-one-shared-JUCE-free-dylib"><a href="#The-fix-one-shared-JUCE-free-dylib" class="headerlink" title="The fix: one shared, JUCE-free dylib"></a>The fix: one shared, JUCE-free dylib</h3><p><code>WomackCommon</code> became a <strong>single shared dylib</strong> with an <strong>absolute install name</strong> under a shareddirectory (<code>/Library/Application Support/Womack</code> for installs; <code>~/Library/Application Support/Womack</code>for dev). Every plugin records that one path, so at load time dyld maps <strong>one</strong> copy — one busper process. Now <code>nm</code> shows the symbol <em>imported</em> (<code>U</code>) everywhere:</p><figure class="highlight console"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">$</span><span class="bash"> nm <span class="string">"Womack Resonote"</span> | grep WomackBus3getEv</span></span><br><span class="line">                 U __ZN9WomackBus3getEv          # imported from the shared dylib</span><br><span class="line"><span class="meta">$</span><span class="bash"> otool -L <span class="string">"Womack Resonote"</span> | grep Womack</span></span><br><span class="line">    /Library/Application Support/Womack/libWomackCommon.dylib</span><br></pre></td></tr></table></figure><p>There was one more trap I designed around from the start of the rewrite: the dylib’s ABI isdeliberately <strong>JUCE-free</strong> — <code>std::string</code>, a plain <code>std::uint32_t</code> ARGB, plain structs. Why?Each plugin already embeds its own JUCE runtime. If the shared dylib also linked JUCE and Ipassed, say, a <code>juce::Identifier</code> or <code>juce::String</code> across the boundary, I’d be sharing objectsthat reference <strong>JUCE’s global string pool</strong> — but there’d be <em>two</em> JUCE runtimes with two pools.That’s a corruption waiting to happen. Keeping the bus boundary to std types means it links only<code>libc++</code>/<code>libSystem</code> (verifiable with <code>otool -L</code>) and can’t entangle the runtimes. Pluginsconvert <code>juce::String ↔ std::string</code> at the edge.</p><p>The installer ships the dylib as a <strong>required component</strong> (always installed), Developer-ID signedand notarized alongside the plugins.</p><p>The takeaway: <strong>green tests are necessary, not sufficient.</strong> The bus’s <em>logic</em> was alwayscorrect — N clients produce count N. What was wrong lived in the <em>linking</em>, a layer the unittests couldn’t see. <code>nm</code>/<code>otool</code> were the tests that mattered here.</p><hr><h2 id="Part-5-—-Where-it’s-going-shared-musical-context"><a href="#Part-5-—-Where-it’s-going-shared-musical-context" class="headerlink" title="Part 5 — Where it’s going: shared musical context"></a>Part 5 — Where it’s going: shared musical context</h2><p>With one real bus in place, the suite can finally share <em>music</em>, not just presence. The nextpiece (in progress) adds two things over the bus, both opt-in, both symmetric-peer:</p><ol><li><strong>Shared key</strong> — turn on “Sync Key” and changing root/scale in one instance updates theothers (with origin-id + value-compare + an “adopting” guard so it can’t echo forever).</li><li><strong>Frequency-signature awareness</strong> — each Resonote publishes the frequencies it occupies, andevery instance can <em>see the others’</em> occupied frequencies as coloured ghosts on its own curve,with a warning when two land within 50 cents of each other. Put a Resonote on a piano andanother on an organ, and each can carve out its <strong>own</strong> spectral pocket instead of fightingfor the same one. Awareness first; you carve manually; automatic avoidance is on the backlog.</li></ol><p><img src="/images/diagram-frequency-signature-sharing.svg" alt="Frequency-signature sharing: Resonote on piano and Resonote on organ each publish their signature to WomackBus and see the other&#39;s frequencies as ghosts with overlap warnings"></p><p>A future <strong>Womack Ex Machina</strong> on the master bus can then become the <em>leader</em> that drives thewhole suite — but that’s a later chapter.</p><hr><h2 id="How-it’s-built"><a href="#How-it’s-built" class="headerlink" title="How it’s built"></a>How it’s built</h2><p>A few process notes, because they mattered as much as the DSP:</p><ul><li><strong>Worktree per task.</strong> Every change happens on its own git worktree/branch; <code>main</code> only everreceives tested, confirmed work. Multiple build sessions never collide.</li><li><strong>TDD on the pure parts.</strong> Note math, band ranges, chord naming, and the sync helpers are allpure and unit-tested (<code>ResonoteTests</code>, <code>WomackBusTests</code>) before they touch a filter or a UI.</li><li><strong>Subagent-driven development.</strong> Larger features are executed task-by-task with a freshimplementer per task and a spec + code-quality review after each — plus a broad review beforemerge.</li><li><strong>Validate relentlessly.</strong> <code>auval</code> on every build for all three plugins; <code>nm</code>/<code>otool</code> for thelinking guarantees; quit-and-reopen Logic before trusting a change.</li></ul><p><img src="/images/diagram-dev-workflow.svg" alt="Development workflow: worktree and branch, failing test, implement, auval plus unit tests, task review, then merge to main with --no-ff"></p><hr><h2 id="Closing"><a href="#Closing" class="headerlink" title="Closing"></a>Closing</h2><p>The fun of this project has been that the <em>musical</em> problems and the <em>systems</em> problems keepturning out to be the same problem viewed from two sides. A filter that “knows about notes” isequal parts pitch math and a fisheye you can drag. A “suite” is equal parts a shared key and asymbol that must be <code>U</code> and not <code>T</code>. And the recurring humbling lesson — from the scientific-notation cents, to the <code>F +3 +5 +7</code> chord, to the bus that passed its tests while being wrong —is that <em>looking right is not the same as being right.</em> The DAW, the linker, and the musictheory all get a vote.</p><p>More soon, once instances can carve out their own place in the mix.</p><!-- markdownlint-disable-next-line MD036 --><p><em>— Womack Audio</em></p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;I set out to build a small family of macOS audio plugins that feel like they were made by
the same hand and &lt;em&gt;know about each other&lt;/em
      
    
    </summary>
    
    
      <category term="audio" scheme="http://womack.io/tags/audio/"/>
    
      <category term="dsp" scheme="http://womack.io/tags/dsp/"/>
    
      <category term="juce" scheme="http://womack.io/tags/juce/"/>
    
      <category term="cpp" scheme="http://womack.io/tags/cpp/"/>
    
      <category term="plugins" scheme="http://womack.io/tags/plugins/"/>
    
      <category term="macos" scheme="http://womack.io/tags/macos/"/>
    
  </entry>
  
  <entry>
    <title>Teaching a Prediction Loop to Distrust Itself</title>
    <link href="http://womack.io/2026/07/06/Teaching-a-Prediction-Loop-to-Distrust-Itself/"/>
    <id>http://womack.io/2026/07/06/Teaching-a-Prediction-Loop-to-Distrust-Itself/</id>
    <published>2026-07-06T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>Ballpark Genius predicts MLB game outcomes — win probability, projected score, the “PitcherEdge” and “Offense Edge” breakdowns you see on a game page. Behind that is a nightlychampion/challenger loop: tune a candidate model, evaluate it against a fixed holdout set, andpromote it only if it’s genuinely better. Over the course of about a week, I found and fixedthree separate bugs in that loop — each one plausible-looking, each one silently defeating thewhole point of having a holdout set in the first place.</p><p><img src="/images/bpg-game-prediction.png" alt="A Ballpark Genius game prediction: Tigers 63% to win over the Guardians, with a 56% confidence rating and the Pitcher Edge / Offense Edge / Total Runs breakdown that feeds it"></p><p><em>The prediction surface this loop feeds — a real game, produced by the champion model after all three fixes below.</em></p><h2 id="Bug-1-the-eval-set-that-stopped-growing"><a href="#Bug-1-the-eval-set-that-stopped-growing" class="headerlink" title="Bug 1: the eval set that stopped growing"></a>Bug 1: the eval set that stopped growing</h2><p>The gate that decides whether to promote a new model runs entirely against games flagged<code>held_out = true</code>. That flag is a persisted boolean, and it was only ever set by a one-offbackfill script — the daily import path that adds new games as the season progresses never setit. So the holdout set froze at whatever the last manual backfill happened to cover: 120 games,static, while every game imported afterward leaked into the <em>training</em> set instead. Two evalreports run a month apart came back byte-identical — same 120 games, same Brier score to fourdecimal places — because the loop was judging every new candidate against a yardstick thathadn’t moved in a month.</p><p>The fix was to stop treating “mark the holdout set” as a manual, one-time step: <code>importGames</code>now assigns <code>heldOut</code> at insert time for every game, using the same holdout-selection rule thebackfill script used, so the eval set grows deterministically with every import and can’t driftout of sync again. Re-running the backfill once to catch the 67 games that had already leakedbrought the holdout set current — 187 games instead of 120.</p><h2 id="Bug-2-a-fixed-threshold-that-noise-could-clear"><a href="#Bug-2-a-fixed-threshold-that-noise-could-clear" class="headerlink" title="Bug 2: a fixed threshold that noise could clear"></a>Bug 2: a fixed threshold that noise could clear</h2><p>With a live holdout set, the next problem was the promotion gate itself. It compared aggregatecomposite scores against a flat <code>epsilon = 0.005</code> — if the candidate beat the champion by morethan that, it got promoted. But per-game Brier-score noise on a ~180-game holdout runs around±0.02, several times larger than the threshold meant to detect real improvement. The gate wasmostly reacting to noise: on at least one occasion it treated a +0.025 swing that was almostcertainly sampling variance as a genuine, promotion-worthy gain. And because the epsilon wasfixed, a growing holdout set — which should make the gate <em>more</em> discerning as more data comesin — never actually lowered the bar for what counted as a real signal.</p><p>The fix replaced the flat threshold with a paired one-sided significance test on <strong>per-game</strong>composite scores: compute the champion-minus-candidate delta on the same holdout games for bothmodels, and promote only when the lower bound of a 95% confidence interval on that delta clearszero. As the holdout set grows, the standard error shrinks, so smaller real gains becomedetectable over time — the gate gets sharper with more data instead of staying flat forever. Adry run against the live 180-game holdout immediately started returning honest reasons insteadof noisy verdicts: a worse candidate failed on a negative lower bound; a directionally-bettermodel (a real +0.0010 edge, ±0.0066) correctly failed as <em>not yet statistically significant</em>rather than being promoted on what was still mostly noise.</p><h2 id="Bug-3-the-tuner-that-optimized-for-the-wrong-exam"><a href="#Bug-3-the-tuner-that-optimized-for-the-wrong-exam" class="headerlink" title="Bug 3: the tuner that optimized for the wrong exam"></a>Bug 3: the tuner that optimized for the wrong exam</h2><p>Fixing the gate exposed the last problem. The coordinate-descent tuner that produces eachcandidate ran to convergence minimizing composite score on a single ~785-game walk-forwardsample, then got graded on the <em>separate</em> holdout set. That’s an overfitting setup byconstruction: the tuner wins on the sample it was allowed to see and loses on the one it wasn’t— in this case, +0.017 on walk-forward against −0.023 on holdout, which now correctly failed thesignificance gate every single time.</p><p>The fix added an explicit shrinkage-to-champion penalty to the tuning objective —<code>composite + λ · penalty</code>, where the penalty is the range-normalized distance from the currentchampion’s weights — and chose λ by k-fold cross-validation rather than by hand: for eachcandidate λ, tune on k−1 folds and score the held-back fold on pure composite (no penalty term),and keep the λ whose out-of-fold score is best. λ = 0 stays in the search grid, so a genuinelygeneralizing tune is never blocked from winning outright.</p><p>On the same live holdout, the heuristic candidate’s performance went from −0.0234 (overfit) to+0.0003 — and the CV procedure chose λ = 2, which is the tuner correctly concluding that thedata doesn’t support moving off the champion at all. That microscopic +0.0003 edge alsocorrectly fails a companion fix from the same pass: a minimum-effect-size floor on thesignificance gate, added after noticing that heavy shrinkage makes a candidate converge towardthe champion so closely that its paired variance collapses too, letting a statistically“significant” but practically meaningless edge slip through a bare <code>&gt; 0</code> check. Requiring theconfidence interval to clear a real minimum, not just zero, closed that gap.</p><h2 id="Why-the-order-of-these-fixes-mattered"><a href="#Why-the-order-of-these-fixes-mattered" class="headerlink" title="Why the order of these fixes mattered"></a>Why the order of these fixes mattered</h2><p>None of the three bugs were individually exotic — a flag that never got set, a threshold thatdidn’t account for sample noise, an objective that didn’t match its own exam. What made this amulti-week chase rather than a single fix is that each one made the <em>next</em> one look like thereal problem. A frozen holdout set makes a noisy gate look stable, because it’s comparingagainst the same games every time. A noisy gate makes an overfitting tuner look successful,because noise-driven promotions occasionally go through. Only once the eval set was current andthe gate was honestly measuring uncertainty did the tuner’s overfitting become visible as itsown distinct bug rather than getting attributed to “the gate is too strict” or “the holdout istoo small.”</p><p>The loop is mechanically sound now — it evaluates on a live holdout, requires statistically realimprovement at a real effect size, and tunes candidates against an objective that generalizesinstead of memorizing. <code>heuristic_v9</code> is already near-optimal for its current feature set, andneither a re-tuned version of itself nor a logistic-regression challenger beats it meaningfullyright now, which is the gate correctly reporting a true negative rather than a broken positive.Getting further gains from here is a features-and-data problem. It’s no longer aloop-machinery problem, and proving that distinction was most of the actual work.</p><p><em>— James</em></p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Ballpark Genius predicts MLB game outcomes — win probability, projected score, the “Pitcher
Edge” and “Offense Edge” breakdowns you see o
      
    
    </summary>
    
    
      <category term="ballpark-genius" scheme="http://womack.io/tags/ballpark-genius/"/>
    
      <category term="typescript" scheme="http://womack.io/tags/typescript/"/>
    
      <category term="sports-data" scheme="http://womack.io/tags/sports-data/"/>
    
      <category term="machine-learning" scheme="http://womack.io/tags/machine-learning/"/>
    
      <category term="statistics" scheme="http://womack.io/tags/statistics/"/>
    
  </entry>
  
  <entry>
    <title>Teaching Claude Not to FLUSHALL My Redis</title>
    <link href="http://womack.io/2026/06/28/Teaching-Claude-Not-to-FLUSHALL-My-Redis/"/>
    <id>http://womack.io/2026/06/28/Teaching-Claude-Not-to-FLUSHALL-My-Redis/</id>
    <published>2026-06-28T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>Dev, main, and prod all point at the same Redis and the same Postgres on Ballpark Genius. That’s not a design decision I’m proud of, it’s a decision made by “there’s only one of me and eleven of these projects.” It works fine right up until an agent decides <code>redis-cli FLUSHALL</code> is a reasonable way to test a cache bug in a worktree that isn’t prod.</p><p>So I wrote a <code>PreToolUse</code> hook. It sits in front of every Bash call and denies anything that looks destructive against those two: <code>redis-cli DEL</code>/<code>FLUSHALL</code>/<code>SET</code>/<code>HSET</code>/<code>EXPIRE</code>/<code>RENAME</code>, and <code>psql</code> <code>DROP</code>/<code>DELETE</code>/<code>TRUNCATE</code>/<code>UPDATE</code>/anything with <code>ALTER ... DROP|RENAME</code>. Read-only and additive operations pass right through. It’s not a “no touching the database” hook, it’s a “no overwriting production by accident” hook.</p><p>It pairs with a hook that already blocked killing or restarting the dev servers, and with a step in this project’s architecture-check skill that makes me, or an agent reading the skill, state a blast-radius verdict before writing code that touches shared infra. Three separate nets for the same failure mode, because I’ve watched it happen at least once each way.</p><p>The honest reason I built this w/ a hook instead of a note in <code>CLAUDE.md</code>: instructions are advisory, hooks are not. I’d rather over-trust a shell script than a language model’s reading comprehension when the blast radius is “the database everyone’s using.”</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Dev, main, and prod all point at the same Redis and the same Postgres on Ballpark Genius. That’s not a design decision I’m proud of, it’s
      
    
    </summary>
    
    
      <category term="claude-code, redis, postgres, hooks, ballpark-genius" scheme="http://womack.io/tags/claude-code-redis-postgres-hooks-ballpark-genius/"/>
    
  </entry>
  
  <entry>
    <title>The AVG Leaderboard Had a 3-At-Bat Batting Champion</title>
    <link href="http://womack.io/2026/06/26/The-AVG-Leaderboard-Had-a-3-At-Bat-Batting-Champion/"/>
    <id>http://womack.io/2026/06/26/The-AVG-Leaderboard-Had-a-3-At-Bat-Batting-Champion/</id>
    <published>2026-06-26T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>For a while, Ballpark Genius’s batting average leaderboard was topped by Justin Dean. Great glove, fine guy, 3 at-bats on the season. Samad Taylor (61 AB) and Tommy Edman (25 AB) were up there too. Aaron Judge was nowhere in sight.</p><p>The batting-average and ERA/WHIP leaderboards are supposed to carry a minimum sample size, at-bats for hitters, innings for pitchers, otherwise you get exactly this: someone who went 2-for-3 outranking someone who went 180-for-560. The backend already supported it. <code>getStatsLeaders</code> in the API client accepts <code>minAtBats</code>/<code>minInningsPitched</code>, the route maps them, the SQL applies them as <code>AND at_bats &gt;= N</code>. The one place that was supposed to send them, a single call site in the leaderboards page, just didn’t. It only passed <code>minGames</code>.</p><p>One missing line, in other words, was the entire distance between “AVG leaderboard” and “who’s had the luckiest 3 at-bats this week.”</p><p><img src="/images/bpg-avg-leaders.png" alt="Ballpark Genius batting average leaderboard after the fix, led by Yordan Alvarez at .322 across 117 games played"></p><p>Fixed, and now the real floors apply, 200+ AB for AVG/OPS, 50+ IP for ERA/WHIP. Judge, Alvarez, Arraez, the names you’d actually expect.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;For a while, Ballpark Genius’s batting average leaderboard was topped by Justin Dean. Great glove, fine guy, 3 at-bats on the season. Sam
      
    
    </summary>
    
    
      <category term="postgres, sql, bugs, ballpark-genius" scheme="http://womack.io/tags/postgres-sql-bugs-ballpark-genius/"/>
    
  </entry>
  
  <entry>
    <title>The Blue Flash</title>
    <link href="http://womack.io/2026/06/26/The-Blue-Flash/"/>
    <id>http://womack.io/2026/06/26/The-Blue-Flash/</id>
    <published>2026-06-26T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>Switch stat categories on the Ballpark Genius leaderboards page and, for about 150ms, the whole table used to flash blue. Not a bug anyone filed, just a thing I kept seeing out of the corner of my eye until I finally sat down to fix it.</p><p>The overlay meant to cover stale rows while new ones loaded used <code>bg-card</code> for its background and <code>bg-muted</code> for the skeleton shapes. In this theme both of those resolve to blue-tinted dark values, so every stat switch painted a blue rectangle over black-and-grey rows for a beat. Nobody designed that. It’s just what two independently-reasonable Tailwind classes do once you stack them.</p><p><img src="/images/bpg-hr-leaders.png" alt="Ballpark Genius home run leaderboard, the current polished state the blue flash used to interrupt on every stat switch"></p><p>The fix was to stop covering the content and dim it instead: drop the skeleton overlay div entirely, set the real rows to <code>opacity: 0.4</code> while fetching, block clicks with <code>pointerEvents: none</code> until the new data lands. Real colors stay real colors, just quieter for a moment. Skeleton rows, the <code>animate-pulse</code> placeholders, are still there for a true cold load with nothing cached yet. They just don’t fire on every category switch anymore.</p><p>Chasing that down surfaced a second, uglier one: switching from a hitting category to a pitching category for the first time in a session returned no cached data for that query key, so the whole table got replaced by skeleton rows instead of just dimming. Fixed by keeping a ref to the last non-empty result set and falling back to it across query key changes, so the previous category’s rows sit underneath as the background while the new ones fade in over top.</p><p>Small bug. Embarrassingly satisfying to finally kill.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Switch stat categories on the Ballpark Genius leaderboards page and, for about 150ms, the whole table used to flash blue. Not a bug anyon
      
    
    </summary>
    
    
      <category term="react, react-query, css, ux, ballpark-genius" scheme="http://womack.io/tags/react-react-query-css-ux-ballpark-genius/"/>
    
  </entry>
  
  <entry>
    <title>A Week of CI Whack-a-Mole</title>
    <link href="http://womack.io/2026/06/20/A-Week-of-CI-Whack-a-Mole/"/>
    <id>http://womack.io/2026/06/20/A-Week-of-CI-Whack-a-Mole/</id>
    <published>2026-06-20T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>Every fix in this post unblocked the next failure. That’s the honest shape of a CI week: you don’t find six bugs, you find one bug wearing five disguises.</p><p>It started with GitHub Actions deprecating Node 20 on its runners, a warning on every job, while all four CI jobs still hardcoded <code>node-version: 20</code> against a local/prod toolchain already on Node 24 via <code>.nvmrc</code>. Switched every <code>setup-node</code> step to <code>node-version-file: .nvmrc</code> so CI can’t drift from the one file that’s supposed to be the source of truth, again.</p><p>That unstuck the next one: <a href="https://github.com/typicode/husky" target="_blank" rel="noopener">husky</a>‘s <code>prepare</code> script runs on every <code>npm install</code>, including Vercel’s production install, where there’s no <code>.git</code> directory and husky exits non-zero. Vercel deploys had been failing on that alone. Gated it behind a <code>.git</code> existence check.</p><p>Past that, <code>tsc</code> failed with about 250 errors, all downstream of one thing: nobody had run <code>prisma generate</code> before it. Vercel caches <code>node_modules</code> and skips regeneration by default. GitHub CI only worked because its build job ran <code>prisma generate</code> explicitly first. Added it as a <code>postinstall</code> and prepended it to <code>build</code>, so the client exists before <code>tsc</code> touches it in any environment. <code>prisma generate</code> only reads <code>schema.prisma</code>, no <code>DATABASE_URL</code> required, so it’s safe in a DB-less build too.</p><p>Then the deployed function crashed at import: winston’s <code>File</code> transport calls <code>mkdirSync(&#39;logs&#39;)</code> on construction, and Vercel’s filesystem is read-only. Gated the file transports on a serverless check (<code>VERCEL</code>/<code>AWS_LAMBDA_FUNCTION_NAME</code>) and wrapped construction in try/catch. Console transport is enough there, Vercel captures stdout anyway.</p><p>None of this makes the API actually happy running on Vercel, that’s a persistent Fastify server with <code>node-cron</code> and a live Redis connection, a different problem for a different day, tracked and deliberately backlogged. This week was just about getting the pipeline green again without six people’s worth of “works on my machine.”</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Every fix in this post unblocked the next failure. That’s the honest shape of a CI week: you don’t find six bugs, you find one bug wearin
      
    
    </summary>
    
    
      <category term="ci, vercel, prisma, husky, github-actions, ballpark-genius" scheme="http://womack.io/tags/ci-vercel-prisma-husky-github-actions-ballpark-genius/"/>
    
  </entry>
  
  <entry>
    <title>Ballpark Genius Now Knows What&#39;s Happening Today</title>
    <link href="http://womack.io/2026/05/13/Ballpark-Genius-Now-Knows-What-Is-Happening-Today/"/>
    <id>http://womack.io/2026/05/13/Ballpark-Genius-Now-Knows-What-Is-Happening-Today/</id>
    <published>2026-05-13T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>Up to now, Ballpark Genius was entirely a look-at-the-past site, projections and season stats, nothing about the game happening right now. Added a whole vertical for that: <code>today.service</code> polls the MLB Stats API every 5 minutes, imports today’s schedule, and for any game that’s gone Final since the last poll, pulls the boxscore and writes the batting/pitching lines. The MCP tool for boxscores hands back formatted text instead of JSON, so those get parsed and upserted under a synthesized retrosheet-style id, <code>MLB{gamePk}</code>, since MLB doesn’t have one yet for a game that just ended.</p><p><img src="/images/bpg-today-games.png" alt="Ballpark Genius homepage Today&#39;s Games rail, six games with real start times and live records"></p><p>Four endpoints under <code>/api/today</code>, each cached 5 minutes, and search got a new intent detector, <code>detectTodayIntent()</code>, that recognizes four shapes of question: what games are on today, who’s leading today, how’d a specific player do today, how’d a specific team do today. It slots into the existing semantic search response as an optional field rather than a separate code path, so “what’s the score of the Yankees game” and “who’s on pace for 40 home runs” both come out of the same box.</p><p>On the frontend, a <code>TodayBand</code> widget on the homepage, a game rail plus tabs for home runs, strikeouts, and total bases, refetching itself every 5 minutes via <a href="https://tanstack.com/query/latest" target="_blank" rel="noopener">react-query</a>. It also seeds the plumbing for live in-progress linescores, which isn’t built yet, <code>GameCardLive</code> already accepts a <code>linescore</code> prop, there’s just nothing populating it. That’s its own ticket. Shipping the “today” layer without the “live, mid-game” layer felt like the right cut. One is a poll every 5 minutes against data MLB has already finalized. The other is a websocket-shaped problem I didn’t want to solve in the same afternoon.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Up to now, Ballpark Genius was entirely a look-at-the-past site, projections and season stats, nothing about the game happening right now
      
    
    </summary>
    
    
      <category term="react-query, typescript, mlb-stats-api, ballpark-genius" scheme="http://womack.io/tags/react-query-typescript-mlb-stats-api-ballpark-genius/"/>
    
  </entry>
  
  <entry>
    <title>Escape Should Mean Never Mind, Not Goodbye</title>
    <link href="http://womack.io/2026/05/12/Escape-Should-Mean-Never-Mind-Not-Goodbye/"/>
    <id>http://womack.io/2026/05/12/Escape-Should-Mean-Never-Mind-Not-Goodbye/</id>
    <published>2026-05-12T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>The Ballpark Genius CLI has a Levenshtein-based suggestion dropdown, type part of a command, it guesses the rest. Escape was supposed to back out of whatever you’re doing. Instead it quit the whole program the moment the dropdown wasn’t visible, even if you were mid-sentence typing something with real text already in the input box. You’d tap Escape out of habit, meaning “close this dropdown,” and the CLI would just leave.</p><p>Fixed it as a priority chain instead of one flat check: if the dropdown’s showing, close the dropdown. If it’s not, but there’s text in the input, clear the input. Only quit the program if neither applies, dropdown hidden and input empty. Escape now does the smallest reasonable thing first and only exits as a last resort, which is what everyone means by “hit escape” in literally every other program with a text field.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;The Ballpark Genius CLI has a Levenshtein-based suggestion dropdown, type part of a command, it guesses the rest. Escape was supposed to 
      
    
    </summary>
    
    
      <category term="cli, ux, terminal, ballpark-genius" scheme="http://womack.io/tags/cli-ux-terminal-ballpark-genius/"/>
    
  </entry>
  
  <entry>
    <title>Jonah Heim Was Leading the Home Run Race</title>
    <link href="http://womack.io/2026/05/11/Jonah-Heim-Was-Leading-the-Home-Run-Race/"/>
    <id>http://womack.io/2026/05/11/Jonah-Heim-Was-Leading-the-Home-Run-Race/</id>
    <published>2026-05-11T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>For a stretch, Ballpark Genius’s home run leaderboard had Jonah Heim on top. One home run. The query behind it filtered <code>WHERE team_id = 0</code> to get season-total rows instead of per-team splits, correct for a player who’s been traded, MLB gives those a <code>team_id = 0</code> aggregate row, and wrong for literally everyone else, who only has per-team rows. That left about seven hitters and eight pitchers as the entire eligible pool for every leaderboard on the site. Whoever had the highest single stat among that tiny, mostly-irrelevant group won.</p><p><img src="/images/bpg-hr-leaders.png" alt="Ballpark Genius home run leaderboard after the fix, Yordan Alvarez and Kyle Schwarber tied at 35"></p><p>Fixed with one query instead of two: <code>DISTINCT ON (player_id) ORDER BY player_id, team_id ASC</code> picks the <code>team_id = 0</code> row for anyone who has one and falls through to their single per-team row otherwise, then the outer query sorts and takes the top N like it always should have. Checked it live after: Judge, Alvarez, Schwarber, back where they belong.</p><p>Small, dumb, expensive bug. The kind where the fix is three lines and the entire distance between “leaderboard” and “not a leaderboard” was one wrong WHERE clause the whole time.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;For a stretch, Ballpark Genius’s home run leaderboard had Jonah Heim on top. One home run. The query behind it filtered &lt;code&gt;WHERE team_
      
    
    </summary>
    
    
      <category term="postgres, sql, bugs, ballpark-genius" scheme="http://womack.io/tags/postgres-sql-bugs-ballpark-genius/"/>
    
  </entry>
  
  <entry>
    <title>The Predictions Were Decorative</title>
    <link href="http://womack.io/2026/05/10/The-Predictions-Were-Decorative/"/>
    <id>http://womack.io/2026/05/10/The-Predictions-Were-Decorative/</id>
    <published>2026-05-10T00:00:00.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>Ballpark Genius has a champion/challenger loop that tunes a new prediction model every night and promotes it if it’s actually better. It promoted <code>heuristic_v9</code> over the baseline weeks ago. The live API kept using the baseline’s math anyway, just with a <code>v9</code> label stapled to it.</p><p><code>generateGamePrediction()</code> had the weights hardcoded as literals, <code>eraSwingMax</code>, <code>parkRunFactorWeight</code>, <code>homeAdvantageBase</code>, the works, regardless of which model version the database said was champion. The tuner could promote a model with meaningfully different weights and the prediction endpoint would go on doing exactly what it had always done. Self-improving in name only.</p><p><img src="/images/bpg-game-sox-jays.png" alt="Ballpark Genius game prediction, Red Sox 57% over Blue Jays, driven by the champion model&#39;s actual weights instead of hardcoded ones"></p><p>Fix was to actually read the champion: <code>getCurrentChampion()</code> loads its weights once at the top of <code>generateGamePrediction</code>, and <code>calculateWinProbability</code> takes them as a parameter instead of reaching for a constant. Checked it against a real game, Nationals at Marlins on May 10th: 38% home win probability under v9, 46% under v0, an 8-point swing from wiring alone, no new data, no new model, just finally using the one that had already won. Across that week’s slate of 92 games, v9 picks the home team 47% of the time vs v0’s 65%. The old hardcoded weights had a structural home-field bias that was never supposed to survive a real promotion.</p><p>Somewhere on this project there’s a lesson about verifying the thing you shipped is the thing that’s running. I already knew that lesson. Here we are anyway.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Ballpark Genius has a champion/challenger loop that tunes a new prediction model every night and promotes it if it’s actually better. It 
      
    
    </summary>
    
    
      <category term="machine-learning, typescript, postgres, ballpark-genius" scheme="http://womack.io/tags/machine-learning-typescript-postgres-ballpark-genius/"/>
    
  </entry>
  
  <entry>
    <title>Experimenting with the experimental in Node 20 and beyond</title>
    <link href="http://womack.io/2023/07/14/Experimenting-with-the-experimental-in-Node-20-and-beyond/"/>
    <id>http://womack.io/2023/07/14/Experimenting-with-the-experimental-in-Node-20-and-beyond/</id>
    <published>2023-07-14T09:30:36.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>I took 30m to experiment with <a href="https://nodejs.org/en/blog/release/v20.0.0" target="_blank" rel="noopener">Node v20</a> yesterday, initially focused on <a href="https://nodejs.org/api/permissions.html" target="_blank" rel="noopener">experimental permissions</a>. I noticed features that have been introduced since Node 14 that apply to Sports Card Investor’s engineering org, e.g. corepack.</p><h2 id="Node-CorePack"><a href="#Node-CorePack" class="headerlink" title="Node/CorePack"></a>Node/CorePack</h2><p>One of those is Node’s corepack, which holds pnpm as an add’l official package manager that it will manage for you. You enable corepack like <a href="https://github.com/jameswomack/nice-to-node-you/blob/main/setup.sh#L6" target="_blank" rel="noopener">so</a>. You declare which package manager and version thereof your Node project is using like <a href="https://github.com/jameswomack/nice-to-node-you/blob/main/package.json#L10" target="_blank" rel="noopener">so</a></p><h2 id="Node-Test"><a href="#Node-Test" class="headerlink" title="Node::Test"></a>Node::Test</h2><p>Another is the introduction of the Node-native <code>test</code> package, which works really nicely with <code>assert</code>. <code>assert</code> has always been useful for Node projects but the introduction of test makes it so that jest is just not necessary for many projects</p><h2 id="Experimental-Permissions"><a href="#Experimental-Permissions" class="headerlink" title="Experimental Permissions"></a>Experimental Permissions</h2><p><a href="https://nodejs.org/en/blog/release/v20.0.0" target="_blank" rel="noopener">Node 20</a> now has a method for locking down which system aspects your code can access &amp; have side effects on. I’m predicting that this feature will also be useful for debugging issues related to third-party code interacting with the file system (e.g. <em>“what the hell is mutating this file?”</em>). <a href="https://github.com/jameswomack/nice-to-node-you/blob/main/package.json#L8" target="_blank" rel="noopener">Here</a> is an example of making FS permissions read-only and limited to the current directory &amp; its descendents. Experimental Node API usage triggers warning messages by default, I’ve also used an environment variable to silence those—the point of that repo is using experimental APIs</p><h2 id="Tying-it-all-together"><a href="#Tying-it-all-together" class="headerlink" title="Tying it all together"></a>Tying it all together</h2><p>By running <a href="https://github.com/jameswomack/nice-to-node-you/blob/main/index.js#L5" target="_blank" rel="noopener">these tests</a> with <code>pnpm</code> I’m combining <code>corepack</code>, <code>test</code> &amp; <code>--experimental-permissions</code> to verify that FS reads are enabled in the project directory, while FS writes are not</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;I took 30m to experiment with &lt;a href=&quot;https://nodejs.org/en/blog/release/v20.0.0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Node v20&lt;/a&gt; yesterday,
      
    
    </summary>
    
    
      <category term="node, nodejs, javascript, 10-percent-time, open-source, node-20, experimental" scheme="http://womack.io/tags/node-nodejs-javascript-10-percent-time-open-source-node-20-experimental/"/>
    
  </entry>
  
  <entry>
    <title>A modern Node.js &#39;cluster&#39; example</title>
    <link href="http://womack.io/2021/03/24/A-modern-Node-js-cluster-example/"/>
    <id>http://womack.io/2021/03/24/A-modern-Node-js-cluster-example/</id>
    <published>2021-03-24T08:54:45.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>An example of running cluster on Node 14, including w/ ESM &amp; semantic updates for ES2020</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> cluster <span class="keyword">from</span> <span class="string">'cluster'</span>;</span><br><span class="line"><span class="keyword">import</span> &#123;createServer&#125; <span class="keyword">from</span> <span class="string">'http'</span>;</span><br><span class="line"><span class="keyword">import</span> &#123;cpus&#125; <span class="keyword">from</span> <span class="string">'os'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> isMainCluster = cluster.isMaster;</span><br><span class="line"><span class="keyword">const</span> &#123;<span class="attr">length</span>:CPUCount&#125; = cpus();</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> (isMainCluster) &#123;</span><br><span class="line">  <span class="built_in">console</span>.log(<span class="string">`Main <span class="subst">$&#123;process.pid&#125;</span> is running`</span>);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// Fork workers.</span></span><br><span class="line">  <span class="keyword">for</span> (<span class="keyword">let</span> currentCPU = <span class="number">0</span>; currentCPU &lt; CPUCount; currentCPU++)</span><br><span class="line">    cluster.fork();</span><br><span class="line"></span><br><span class="line">  cluster.on(<span class="string">'exit'</span>, worker =&gt;</span><br><span class="line">    <span class="built_in">console</span>.log(<span class="string">`worker <span class="subst">$&#123;worker.process.pid&#125;</span> died`</span>));</span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">  <span class="comment">// Workers can share any TCP connection</span></span><br><span class="line">  <span class="comment">// In this case it is an HTTP server</span></span><br><span class="line">  createServer(<span class="function">(<span class="params">req, res</span>) =&gt;</span> &#123;</span><br><span class="line">    res.writeHead(<span class="number">200</span>);</span><br><span class="line">    res.end(<span class="string">'hello world\n'</span>);</span><br><span class="line">  &#125;).listen(<span class="number">8000</span>);</span><br><span class="line"></span><br><span class="line">  <span class="built_in">console</span>.log(<span class="string">`Worker <span class="subst">$&#123;process.pid&#125;</span> started`</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Changes from the current Node.js ‘cluster’ docs include:</p><ol><li>Using <code>import</code> over <code>require</code></li><li>Using named imports over the default where feasible </li><li>Using clearer, more semantic identifier names, e.g. <code>currentCPU</code> v. <code>i</code></li><li>Replacing archaic terminology, e.g. <code>Master</code> w/ <code>Main</code></li></ol><p>Output w/ ESM Node code w/ syntactical &amp; semantic updates<figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">&gt; $ node index.mjs                                                                       ⬡ 14.15.1 [±main ●]</span><br><span class="line">Main 57290 is running</span><br><span class="line">Worker 57292 started</span><br><span class="line">Worker 57295 started</span><br><span class="line">Worker 57291 started</span><br><span class="line">Worker 57294 started</span><br><span class="line">Worker 57293 started</span><br><span class="line">Worker 57296 started</span><br><span class="line">Worker 57298 started</span><br><span class="line">Worker 57297 started</span><br><span class="line">Worker 57299 started</span><br><span class="line">Worker 57300 started</span><br><span class="line">Worker 57302 started</span><br><span class="line">Worker 57301 started</span><br></pre></td></tr></table></figure></p><p>See <a href="https://github.com/jameswomack/modern-node_cluster-example" target="_blank" rel="noopener">here</a> for the full repo</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;An example of running cluster on Node 14, including w/ ESM &amp;amp; semantic updates for ES2020&lt;/p&gt;
&lt;figure class=&quot;highlight js&quot;&gt;&lt;table&gt;&lt;tr&gt;
      
    
    </summary>
    
    
      <category term="node, javascript, modern, cluster" scheme="http://womack.io/tags/node-javascript-modern-cluster/"/>
    
  </entry>
  
  <entry>
    <title>Finding unDRY code w/ jsinspect</title>
    <link href="http://womack.io/2020/04/21/Finding-unDRY-code-w-jsinspect/"/>
    <id>http://womack.io/2020/04/21/Finding-unDRY-code-w-jsinspect/</id>
    <published>2020-04-21T14:39:22.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>One of the constants in my software dev career has been a predilection for reducing redundant code as much as feasible. There’s a JSX-fluent CLI tool that been helping me do that at iTRVL.</p><p>Running<code>npx jsinspect ./packages/agent/src/components/Itinerary/EditItinerary/*Step.js</code> will give you a set outputs like so:<figure class="highlight groovy"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br></pre></td><td class="code"><pre><span class="line">.<span class="regexp">/packages/</span>agent<span class="regexp">/src/</span>components<span class="regexp">/Itinerary/</span>EditItinerary/FlightStep.<span class="string">js:</span><span class="number">132</span>,<span class="number">136</span></span><br><span class="line">&lt;EditableInput</span><br><span class="line">  textValue=&#123;formatMoney(step.sell, <span class="string">'USD'</span>)&#125;</span><br><span class="line">  inputValue=&#123;step.sell&#125;</span><br><span class="line">  editOnly=&#123;<span class="literal">true</span>&#125;</span><br><span class="line">  inputProps=&#123;&#123; <span class="string">className:</span> classes.editableAlignRight &#125;&#125;</span><br><span class="line"></span><br><span class="line">.<span class="regexp">/packages/</span>agent<span class="regexp">/src/</span>components<span class="regexp">/Itinerary/</span>EditItinerary/RoadStep.<span class="string">js:</span><span class="number">136</span>,<span class="number">140</span></span><br><span class="line">&lt;EditableInput</span><br><span class="line">  textValue=&#123;formatMoney(step.sell, <span class="string">'USD'</span>)&#125;</span><br><span class="line">  inputValue=&#123;step.sell&#125;</span><br><span class="line">  editOnly=&#123;<span class="literal">true</span>&#125;</span><br><span class="line">  inputProps=&#123;&#123; <span class="string">className:</span> classes.editableAlignRight &#125;&#125;</span><br><span class="line"></span><br><span class="line">------------------------------------------------------------</span><br><span class="line"></span><br><span class="line">Match - <span class="number">2</span> instances</span><br><span class="line"></span><br><span class="line">.<span class="regexp">/packages/</span>agent<span class="regexp">/src/</span>components<span class="regexp">/Itinerary/</span>EditItinerary/RoadStep.<span class="string">js:</span><span class="number">94</span>,<span class="number">97</span></span><br><span class="line">&lt;Paper className=<span class="string">"stepLine"</span> elevation=&#123;<span class="number">0</span>&#125; style=&#123;&#123; <span class="string">marginLeft:</span> <span class="string">'0px'</span> &#125;&#125;&gt;</span><br><span class="line">  &lt;Box className=<span class="string">"header"</span>&gt;</span><br><span class="line">    &lt;Box className=<span class="string">"type-icon"</span>&gt;</span><br><span class="line">      &lt;AirportShuttle /&gt;</span><br><span class="line"></span><br><span class="line">.<span class="regexp">/packages/</span>agent<span class="regexp">/src/</span>components<span class="regexp">/Itinerary/</span>EditItinerary/StayStep.<span class="string">js:</span><span class="number">284</span>,<span class="number">287</span></span><br><span class="line">&lt;Paper className=<span class="string">"stepLine"</span> elevation=&#123;<span class="number">0</span>&#125; style=&#123;&#123; <span class="string">marginLeft:</span> <span class="string">'0px'</span> &#125;&#125;&gt;</span><br><span class="line">  &lt;Box className=<span class="string">"header"</span>&gt;</span><br><span class="line">    &lt;Box className=<span class="string">"type-icon"</span>&gt;</span><br><span class="line">      &lt;Tipi /&gt;</span><br><span class="line"></span><br><span class="line">------------------------------------------------------------</span><br></pre></td></tr></table></figure></p><p>This is especially useful for finding unDRY code in large codebases that you are new to or aren’t wholly familiar with</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;One of the constants in my software dev career has been a predilection for reducing redundant code as much as feasible. There’s a JSX-flu
      
    
    </summary>
    
    
      <category term="javascript, js, jsinpect, DRY" scheme="http://womack.io/tags/javascript-js-jsinpect-DRY/"/>
    
  </entry>
  
  <entry>
    <title>IFTTT &amp; the Augmented Human</title>
    <link href="http://womack.io/2016/08/20/augmented/"/>
    <id>http://womack.io/2016/08/20/augmented/</id>
    <published>2016-08-20T21:02:11.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>I’ve been an IFTTT user for a while but only recently have I been trying to use it to its fullest extent. Apps like IFTTT &amp; Zapier are an early version of augmenting ourselves.</p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;I’ve been an IFTTT user for a while but only recently have I been trying to use it to its fullest extent. Apps like IFTTT &amp;amp; Zapier ar
      
    
    </summary>
    
    
  </entry>
  
  <entry>
    <title>The everlasting benefit of naming conventions</title>
    <link href="http://womack.io/2016/04/18/The-everlasting-benefit-of-naming-conventions/"/>
    <id>http://womack.io/2016/04/18/The-everlasting-benefit-of-naming-conventions/</id>
    <published>2016-04-18T16:21:23.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>Take a look at this example <strong>.tern-project</strong> file<figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  <span class="attr">"libs"</span>: [</span><br><span class="line">    <span class="string">"browser"</span>,</span><br><span class="line">    <span class="string">"ecma5"</span>,</span><br><span class="line">    <span class="string">"ecma6"</span></span><br><span class="line">  ],</span><br><span class="line">  <span class="attr">"loadEagerly"</span>: [</span><br><span class="line">    <span class="string">"./node_modules/abacus-notepad-component/dist/*.js"</span>,</span><br><span class="line">    <span class="string">"./node_modules/activity-component/lib/*.js"</span>,</span><br><span class="line">    <span class="string">"./node_modules/component-popup/src/popup.jsx"</span>,</span><br><span class="line">    <span class="string">"./server/**/*.js"</span>,</span><br><span class="line">    <span class="string">"./server/*.js"</span>,</span><br><span class="line">    <span class="string">"./client/src/js/**/*.js"</span></span><br><span class="line">  ],</span><br><span class="line">  <span class="attr">"plugins"</span>: &#123;</span><br><span class="line">    <span class="attr">"node"</span>: &#123;&#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></p><p>We’re using TernJS and it’s loadEagerly option to have intelligent &amp; dynamic autocomplete available in our JavaScript editor setup. It works anywhere from vim to Visual Studio Code. But I digress. </p><p>As out project grows, we add more entries. As our number of projects grow, it will likely get copied all over the place, including onto other developers’ machines that you do not control. Even if you did automate (and even control) the propogation and maintenance of this file, you’d have a problem: the module names, file paths and file names aren’t normalized. A file path is an address. Addresses are normalized in society because they serve a purpose that is not reached if humans cannot make assumptions about them. When identifying structures within the United States of America, addresses are generally normalized to meet the following assumptions:</p><ul><li>The first piece of the identifier is real number, with the vast majority being integers (a small percentage have a vulgar fraction appended). Most importantly, the overwhelming majority of US streets have the odd numbers on one side of the street and the even numbers on the other. Anecdotal evidence shows this to be even more important than chromatic sequence when locating a structure</li><li>The second piece is almost always the name of the thoroughfare touching the land nearest the official entrance to the structure</li><li>The (optional) third piece is a sub-identifier representing that identity of the unit within the structure identified by the preceding and succeeding pieces</li><li>The fourth piece is the city name</li><li>The fifth piece is the state name</li><li>The sixth piece is the Zone Improvement Code or ZIP code. It consist of five- and four-digit integer separated by a hypen (or “dash”). This is, in my opinion, one of the weaker parts of the address system as most folks do not know the 4 digit appendage that was introduced in 1983, nor do they usually know many ZIP codes other than their own</li></ul><p>Just as the home address system in the USA does, a file path convention will have stronger and weaker aspects to it. All the same, we all know having an address system is better than none, so why would you not have one for files? In retrospect it becomes an obvious choice.</p><p>Take a look at an improved <strong>.tern-project</strong> file, taking into account the lessons of the address system<figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  <span class="attr">"libs"</span>: [</span><br><span class="line">    <span class="string">"browser"</span>,</span><br><span class="line">    <span class="string">"ecma5"</span>,</span><br><span class="line">    <span class="string">"ecma6"</span></span><br><span class="line">  ],</span><br><span class="line">  <span class="attr">"loadEagerly"</span>: [</span><br><span class="line">    <span class="string">"./node_modules/abacus-*-component/lib/**/*.js"</span>,</span><br><span class="line">    <span class="string">"./lib/**/*.js"</span></span><br><span class="line">  ],</span><br><span class="line">  <span class="attr">"plugins"</span>: &#123;</span><br><span class="line">    <span class="attr">"node"</span>: &#123;&#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure></p><p>What changes did we make?</p><ul><li>Name all our team’s custom components using the format teamname-modulename-component</li><li>Always put our transpiled/consumable JavaScript files in a folder named lib, organized into appropriate subfolders&amp; always using the extension .js. This is the convention, whether it’s a small package or an application</li></ul><p>That’s it! We went from 6 entries to 2 just like that.</p><p>Happy filemaking! </p>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;Take a look at this example &lt;strong&gt;.tern-project&lt;/strong&gt; file
&lt;figure class=&quot;highlight json&quot;&gt;&lt;table&gt;&lt;tr&gt;&lt;td class=&quot;gutter&quot;&gt;&lt;pre&gt;&lt;span c
      
    
    </summary>
    
    
      <category term="tern, tern-project, naming, dotfile, ternjs, naming, conventions" scheme="http://womack.io/tags/tern-tern-project-naming-dotfile-ternjs-naming-conventions/"/>
    
  </entry>
  
  <entry>
    <title>Automating the hiring process</title>
    <link href="http://womack.io/2016/04/14/Automating-the-hiring-process/"/>
    <id>http://womack.io/2016/04/14/Automating-the-hiring-process/</id>
    <published>2016-04-14T15:07:35.000Z</published>
    <updated>2026-08-11T13:15:53.208Z</updated>
    
    <content type="html"><![CDATA[<p>A representative of an organization called TestDome Ltd contacted me a couple days ago. Today, they pinged me again, asking “Any thoughts on this?”. What they wanted my thoughts on was whether I could use their service of automating using programming quizzes to filter out folks in the (often long-winding) hiring process. My answer was as follows:</p><blockquote><p>My thought is that this type of automation cannot solve the type of hiring problems we have at Netflix. Programming tests embody an extremely poor evaluation of senior programmers. Senior programmers leverage their past experience to effectively combine the best existing solutions in such a way that they’ve created some new, maintainable and sustainable. Senior programmers are good at working with others. They conform to, while incrementally improving, coding style and standards. The only way to pre-suppose about these things is looking through open source contributions, Stack Overflow answers, behavior on Twitter and then pair programming with them. An automated programming quiz merely tests how long they’ve spent doing automated programming quizzes.</p></blockquote>]]></content>
    
    <summary type="html">
    
      
      
        &lt;p&gt;A representative of an organization called TestDome Ltd contacted me a couple days ago. Today, they pinged me again, asking “Any thoughts
      
    
    </summary>
    
    
      <category term="hiring, recruiting, automation" scheme="http://womack.io/tags/hiring-recruiting-automation/"/>
    
  </entry>
  
</feed>
