When Zero Means Zero: An LLM Safety Net That Ate Real Queries

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 it into a structured filter against Postgres. LLMs being LLMs, they occasionally hallucinate a filter nobody asked for: a stray { stat: 'plateAppearances', value: 0, direction: 'above' } tacked onto an otherwise correct parse. 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 0 and the direction is at_least/above/etc.

That net also ate every real query where a user typed “0” on purpose.

Ballpark Genius search results for "players with 0 or more three baggers," now correctly returning 18 sorted, ranked players

“Players with 0 or more three baggers” — a real, syntactically valid query that used to return nothing sensible.

The parse is identical either way

Type “players with 0 or more three baggers” into the search bar, and the LLM parses it to exactly the same shape as the hallucinated placeholder:

1
{ "stat": "triples", "direction": "at_least", "value": 0 }

There’s nothing in the parsed object that distinguishes “the model made this up” from “the user explicitly 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 value === 0 simply never matched — which is exactly why this sat unnoticed for a while. It’s the kind of bug that only shows up on the specific boundary condition nobody thinks to test, because a query for “0 of something” feels like a null query even when it isn’t one.

Losing the threshold broke more than the threshold

The consequence wasn’t just a missing filter — it silently broke sort order too. Downstream, sortBy gets inferred from whichever *_min/*_max filter key survived the parse. Strip the threshold, and there’s no filter key left to infer a sort from, so the query falls back to no sort at all. A request for the top 20 by some stat quietly stopped showing the top 20 — it showed whatever order the database happened to return, with no threshold and no ranking, and no error anywhere in the chain to say so.

The fix: check the query text before you strip

The stripper’s problem was that it only had the parsed shape to go on. The fix gives it a second source of truth: before deleting a zero-valued threshold, re-run that stat’s own threshold regex — the same THRESHOLD_PATTERNS used to parse the query in the first place, defined in stat-aliases.ts — against the raw text the user actually typed. If “0” for that stat has a real textual basis in the query, the threshold survives. If it doesn’t — if there’s no explicit zero anywhere in the sentence — it gets stripped as the hallucination it almost certainly is.

1
2
buildFallbackToolArgs("players with 0 or more three baggers", 'player')
// -> { stat: 'triples', direction: 'at_least', value: 0 } (now survives)

Same parsed shape, same value, but now the decision to keep or drop it is grounded in what the user actually wrote instead of a blanket rule about the number zero.

The broader shape of the bug

This is a pattern worth naming on its own: a safety net built to catch model noise, tuned against the failure case that motivated it, without checking whether it could also match a legitimate 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 whole page of results. It just needed a second signal beyond the parsed value to tell the two cases apart, and that signal — the original query text — was sitting right there the whole time, already used earlier in the same pipeline.

— James


The Bug That Reverted Every Trade: Three Silent Failures Hiding Behind Each Other

Ballpark Genius is an MLB analytics site — player pages, projections, trade tracking, semantic search 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. None of them logged an error. Every one of them printed a success message while doing nothing, or the wrong thing.

This is the write-up, in the order I actually found them, because the order matters: each fix revealed the next failure instead of resolving the symptom.

Adley Rutschman's Ballpark Genius player page, correctly showing "Traded 8 days ago" from Baltimore to Boston and his current team as Boston Red Sox

Adley Rutschman’s page today — the state all three bugs conspired to prevent.

Bug 1: the cache flush that flushed nothing

The first theory was stale cache. I ran the project’s clear-cache script, expected a clean slate, and got the same stale data back. Ran it again. Same result. The script printed ✓ cleared every time.

The cause was an environment mismatch that had been there since the caching layer was built:

1
const CACHE_NAMESPACE = (process.env.CACHE_NAMESPACE || process.env.NODE_ENV || 'dev').trim();

The server runs with NODE_ENV=development and writes keys under development:*. The CLI script, run from a plain shell, has no NODE_ENV set at all — it resolves to dev:, issues a delete against dev:*, matches zero keys, and reports success anyway, because delPattern returned void. A zero-match delete was indistinguishable from a real one, at the type level and in the printed output.

I only found it by going around the tool: redis-cli EXISTS against the development:* keys directly, before and after a “successful” flush. The keys were still there. That’s when I noticed the namespace mismatch.

The fix made a no-op impossible to mistake for success:

  • delPattern now returns the number of keys it actually deleted, and every call site reports that count — ⚠️ 0 entries cleared reads very differently than ✓ cleared.
  • The tool now prints the namespace it’s about to operate on and what Redis actually holds, so a mismatch is visible before you even wait for the delete.
  • It detects the mismatch and tells you the fix:
1
2
3
4
Namespace: dev:  (Redis currently holds: development, test, today)
⚠️ No keys exist under "dev:" — this flush will delete NOTHING.
Keys live under: development:, test:, today:
Re-run matching it, e.g. NODE_ENV=development npm run clear-cache -- ...

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.

Bug 2: the importer that outvoted the trade

With caching ruled out, the database itself said Rutschman was still Baltimore’s. A trade sync job (refresh-player-teams) had correctly written his real club days earlier. Something was writing it back.

The culprit was a stats importer — a job that pulls a player’s current-season batting or pitching splits and, as an incidental side effect, decided it should also own that player’s team assignment:

1
2
3
4
// Update player's team if this is the most recent MLB team ...
if (player && teamId && teamName && season === CURRENT_SEASON && isMLBTeamId(teamId)) {
await prisma.player.update({ where: { id: playerId }, data: { teamId, teamName } });
}

The comment claims “most recent.” There is no recency check anywhere in that block — it simply overwrites with whatever split MLB’s API happens to return next, in whatever order the API happens to return them. For a player traded mid-season, the old club is often the only one with 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 every time 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 updated_at timestamp as the only trace.

The fix was to make that write fill-only: populate a missing team, never overwrite an existing one. A stats importer has no business deciding a player’s current club — that’s what the trade sync and MLB’s own parentOrgId field are for. The importer can still help a player that those other syncs haven’t reached yet, which is the entire reason the write existed in the first place; it just can’t outrank them anymore.

Fixing this repaired 51 players who’d been reverted by exactly this path.

Bug 3: the schema that quietly dropped the answer

There was a third bug feeding into the same symptom, found a few days earlier in the same chase: fix-milb-teams, a job meant to correct minor-league team assignments, saw Rutschman parked on a non-MLB affiliate, decided that looked broken, and “fixed” it by picking whichever MLB club he’d played the most games for historically — which, for a player traded five days ago onto an injured list, is always the old team. Volume-based inference is exactly backwards for someone who just moved.

MLB’s API does hand back the correct answer directly, as currentTeam.parentOrgId. But our Zod schema 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 saw it. The first draft of this fix read parentOrgId, got undefined, and would have quietly fallen back to the same broken most-played-games logic while looking like it had been fixed. I only caught that by checking the parsed output against the raw response, not trusting that adding a schema field was enough on its own.

With parentOrgId declared and read, fix-milb-teams now asks MLB directly instead of inferring from history. Repair applied to the 328 players whose team contradicted their most recent trade: 52 genuinely wrong, corrected; 276 were false positives from the detection query itself — players like Verlander and Scherzer, whose team correctly differs from an old trade because they’ve since moved again on their own. The script checks with MLB before touching anyone, so only the 52 real mismatches changed.

What all three had in common

None of these bugs were exotic. A namespace typo. A missing recency check with a comment that lied about having one. A schema that silently dropped a field. Individually they’re the kind of thing you’d catch in five minutes. What made them dangerous together is that each one’s silence looked exactly like the previous fix working: flush the cache (nothing happens, but it says it worked), so you conclude the data itself must be wrong; fix the importer (the revert stops for a while, until the next stats sync), so you conclude the trade sync must be flaky; add the schema field (still wrong, because the value was never read), so you conclude the API itself must be inconsistent.

The lesson I keep relearning on this project: a tool that can silently do nothing is worse than a 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.

— James


Building Blockbuster Trades: Ranking a Decade of MLB Deals Without Making Up a Score

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 the same asset, and a naive trade feed treats them identically. This is the story of building that ranking end to end — from a primary-key bug that was silently collapsing multi-player trades, to a scoring function whose weights came from grading 201 real trades by hand rather than picking numbers that felt right.

Ballpark Genius trade board, headlined by the Tarik Skubal / Adley Rutschman deals, showing WAR, awards, and control-year estimates for every player involved

The trade board today — the Skubal and Rutschman deals are the two real trades this whole build kept getting checked against.

First, the data had to stop lying about who was in a trade

Before any ranking could matter, the transactions table had a structural bug: Transaction.id was declared as the primary key, but MLB’s Stats API id field is a deal identifier shared by every player in that trade, not a per-person id. A four-player trade upserts four rows that all share one id — so each row overwrote the last, and only one player of every multi-player deal ever survived in the database. The natural key needed to be (dealId, personId), not id alone. Re-keying that was the precondition for everything that followed: you can’t rank a trade’s players if the database only remembers one of them.

With the key fixed, I ran a one-off backfill pulling MLB’s transaction history in monthly windows from 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 Zod error and returned an empty array — so one malformed record failed validation for the entire month, and the caller had no way to distinguish “zero trades this month” from “parsing blew up and we got nothing.” The captured Zod errors pointed at the real cause: some historical records (pre-2020 releases, minor-league moves) omit a name field on the traded-to or traded-from team that the schema had marked required. Making that field optional, and adding a strict variant of the fetch that rethrows instead of swallowing, turned “quietly wrong” into “loudly correct”: the re-run processed all 132 windows with zero failures, landing 513,690 transactions including 5,883 trade records going back a decade.

Estimating control years without contract data

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 that works from what’s actually available: MLB debut date, bucketed into EXPIRING / SHORT / MEDIUM / LONG control windows based on years of service.

Before shipping it, I ran the validation the spec called for: hand-check the 25 active players with the highest career WAR — the worst case for a debut-date-only heuristic, since long-tenured stars are exactly who tends to sign extensions the heuristic can’t see — against their real contract status via web search. 15 of 25 (60%) were false positives. The heuristic was calling players “expiring” who were actually locked up for years.

60% is well above the 25% threshold the spec had set as an acceptable error rate for a rough estimate, so the fix wasn’t a tuning pass — it was changing what the UI claims. The bucket’s display label went from the confident-sounding RENTAL to the honest CONTRACT UNKNOWN, and every control estimate in the product now carries a persistent confidence: 'estimate' flag. The estimator wasn’t good enough to assert a fact, so the product stopped asserting one.

Scoring “blockbuster” against real trades, not intuition

The ranking itself — scoreBlockbuster() — 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 2026 season against three different candidate models — star-dominant, package-weighted, and a WAR-rate model — and picking the one whose output actually matched a human sense of “how big was this trade,” which meant falsifying and discarding the first two before landing on the current one.

Two bugs surfaced in the process worth calling out because both hid behind a passing test suite:

Award matching was silently wrong. The scorer needs to recognize award types like Cy Young and MVP, but the real award_type values in the database are granular — WS_MVP, ALCS_MVP, NLCS_MVP, ALL_STAR_MVP all contain the substring MVP. A naive substring match would score every one of those as a full regular-season MVP. Worse, the test fixture for the headline Skubal trade used awardType: 'AL_CY_YOUNG' — a value that doesn’t exist anywhere in the real data (Cy Young is stored league-neutral as CY_YOUNG, with league in a separate column). That typo meant the fixture silently fell through to the generic unknown-award fallback weight instead of exercising the actual Cy Young weight — so the single highest-value award in the whole config had zero real test coverage, and the test suite was green the entire time. The fix was exact-key matching plus a corrected fixture pulling Skubal’s real award rows straight from the database.

Truncation before filtering. A related bug in the trade-detail page: getPlayerTradeDeals scanned a date range and capped it with limit: 500 before filtering to the player being viewed. Across a full career that window holds thousands of deals, so for a player with enough transaction history, their trade could get cut by the limit before the filter ever ran. The fix was to select by dealId directly instead of truncating a superset and hoping the target survived. This is the same shape of bug as the ranking’s earlier limit-then-filter mistake in a different query — worth naming as a pattern: never truncate a set you’re about to filter.

What actually shows up in the product

The result is what’s on the trade board now: every deal shows real WAR, real awards, and a control-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 deal that ran through every stage of this build as the reference case — four players, real awards, a Cy Young winner headlining one side — is the top-ranked trade of the season, which is also just true.

— James


This Blog Vendors Its Own Static Site Generator Now

womack.io runs on Hexo and a theme called apollo, both from around 2019. package-lock.json was gitignored this whole time, so every fresh install re-resolved every hexo-* plugin’s ^ 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.

The theme is written against Hexo 4.x’s generator and renderer APIs specifically. Upstream Hexo has moved past that, so floating a ^4.2.1 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 packages/hexo now, published locally as @jameswomack/hexo, installed via "hexo": "file:packages/hexo" so it lands in node_modules/hexo right where hexo-cli expects it. Every hexo-* plugin version is pinned exact, no carets, and package-lock.json 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.

Before trusting it I ran hexo generate under Node 20, 22, and 24 and diffed the output. Byte-identical, except sitemap.xml‘s entry order, which turned out to be non-deterministic tie-breaking on same-timestamp posts, not a real difference. .nvmrc moved to 24.7.0 for Vercel the same day.

womack.io, running on the now-vendored Hexo fork

Fifteen minutes after that landed, npm audit flagged send under 0.19.0 for a template injection XSS, GHSA-m6fv-jmcg-4jfg, reachable through hexo-server and hexo-browsersync, 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 send to 0.19.2 and serve-static to 1.16.3 via package.json overrides. Tried jumping serve-static straight to 2.x first, that broke hexo-browsersync‘s plugin loading outright, its wrapper assumes the 1.x API, so back to the patched 1.x line instead.

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.


Building the Womack Audio Suite: note-aware resonance, a fisheye EQ, and a bus that almost wasn't

I set out to build a small family of macOS audio plugins that feel like they were made by the same hand and know about each other: a note-aware resonant filter, a fuzz/vibe/delay pedalboard, a lyric teleprompter — and, underneath them, a shared “bus” so instances can talk. Along the way I fought a filter into warmth without letting it scream, drew an EQ curve through a fisheye lens, threw out a chord detector that couldn’t name basic chords, and spent an afternoon learning why a plugin can work perfectly in Standalone and be stone dead in Logic.

This is the engineering story — the musical decisions and the programming ones — with the war stories left in.

The suite today:

  • Womack FX — a fuzz → univibe → tape-delay pedalboard (aufx).
  • Womack Resonote — a note-quantized resonant filter/EQ, single- and multi-band (aumf).
  • Womack Lyriqueue — session-stored lyrics with a playhead-following teleprompter (aufx).
  • WomackBus — a shared, in-process message bus the plugins register on.

Stack throughout: JUCE 8.0.12, C++20, CMake (Xcode generator), building AU + VST3 + Standalone, unit-tested with JUCE’s console UnitTest runner, signed and notarized for distribution.

Suite architecture: Womack FX, two Resonote instances, and Lyriqueue all registering on one shared WomackBus dylib inside a single DAW process


Part 1 — Resonote: making a filter that knows about notes

Most EQs think in raw Hz. Musicians think in notes. Resonote closes that gap: its cutoff can snap to note frequencies, optionally constrained to a key, with a live cents-from-nearest readout so you always know how in-tune the resonance is.

Note math you can trust

The pitch math lives in a small, pure, unit-tested header (NoteFrequency.h): MIDI↔Hz, nearestMidi, noteName, centsFromNearest, and scale-aware snapping for chromatic, major, and minor. Keeping it pure meant I could test the tricky parts directly instead of poking at a filter and squinting at a spectrum.

One bug from this corner is worth calling out because it’s so ordinary: the cents readout once showed +9.61706e-05 c. juce::String(cents, 0) doesn’t round — it formats — so a whisker above zero printed in scientific notation. The fix was a juce::roundToInt before display. Tiny, but it’s the difference between “precise instrument” and “toy.”

The warmth: one SVF with a tanh in the feedback

The heart of Resonote is a single zero-delay-feedback TPT state-variable filter (ResonantSVF). It runs in Bell, Low-Pass, or High-Pass mode. The only “analog” trick — and it’s deliberately the only one — is a tanh nonlinearity in the resonance feedback path.

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

That tanh does two jobs. It adds a gentle, level-dependent saturation as resonance climbs — the “warmth” — and it self-limits, so the filter stays stable and never tips into self-oscillation. That let me be aggressive with the resonance range. resonanceToQ maps a normalized 0..1 knob to Q ≈ 0.5 … 75:

1
2
3
4
5
6
// 0..1 -> Q 0.5..~75, musically weighted toward the top of the range.
float resonanceToQ (float r) noexcept
{
r = juce::jlimit (0.0f, 1.0f, r);
return 0.5f + std::pow (r, 3.0f) * 74.5f;
}

Cranked, it sings on a note without ever howling — because the tanh in the loop eats the runaway energy that self-oscillation would need.

The fisheye response curve

An EQ curve on a log-frequency axis wastes its most interesting real estate: the region right around where you’re working is cramped. So Resonote’s response display bends space. It draws everything through a horizontal fisheye — an erf “bump” that magnifies the area around the current cutoff and compresses the far edges — plus a subtle convex vertical bow for a tactile “glass” feel.

Schematic of the fisheye response curve

Schematic illustration (not a screenshot).

Womack Resonote single-band in Logic Pro — Low-Pass, cutoff snapped to A3, the fisheye response curve, and the live note/cents readout

The real Resonote UI in Logic Pro — single band, cutoff snapped to A3.

The horizontal mapping from a normalized log-frequency position u ∈ [0,1] to a pixel x is:

1
2
3
4
5
6
// erf "bump" centred on u0 (the cutoff), pinned at the frame edges.
float uToX (float u) const noexcept
{
const float bump = lensStrength * (std::erf ((u - lensU0) / lensWidth) - lensBumpAt0);
return lastArea.getX() + ((u + bump) / lensDenom) * lastArea.getWidth();
}

Because uToX is monotonic increasing in u, it has a well-defined inverse — and I need that inverse constantly: to hit-test the mouse against the curve, and to let you drag the crossover handles between bands and have them land exactly on the boundary they control. Rather than derive a closed-form inverse of an erf, I just bisect:

1
2
3
4
5
6
7
8
9
10
11
12
// uToX is monotonic in u, so bisect for its inverse. ~30 iterations over [0,1]
// is well below sub-pixel at any realistic width.
float xToU (float x) const noexcept
{
float lo = 0.0f, hi = 1.0f;
for (int i = 0; i < 30; ++i)
{
const float mid = 0.5f * (lo + hi);
if (uToX (mid) < x) lo = mid; else hi = mid;
}
return 0.5f * (lo + hi);
}

Thirty iterations is nothing per frame, it’s trivially correct, and it means the exact same warp is used for drawing and for interaction. The handles never drift off the slabs they sit on.

Multiband: up to four bells that don’t step on each other

Resonote grew from one band to up to four, each a colour-coded bell with its own frequency, resonance, and gain, and each confined to a mutually-exclusive range at least an octave wide. A + / – LED changes the band count; adding a band auto-splits the spectrum, after which the crossovers are draggable (through that same fisheye inverse). Each band also gets tempo-synced resonance modulation — an LFO with a per-band depth and a host-synced rate, plus a global shape.

Schematic of the multiband layout

Schematic illustration (not a screenshot).

Womack Resonote multiband in Logic Pro — Band 3 selected, Suite Spectrum on showing another instance's frequency as a ghost marker, and the chord readout

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.

The chord detector I had to rip out and rewrite

The multiband version reads out the chord formed by the active bands. My first attempt was hand-rolled, and it was simply wrong: for the notes of an F minor 7 it displayed F +3 +5 +7 — printing raw semitone offsets as if they were chord degrees. For a music app, that’s just not good enough.

There is no clean drop-in C++ chord library, so I ported the algorithm that the JavaScript world trusts — tonal.js-style detection: normalize the pitch classes, then rotate through every note as a candidate root, match the resulting interval set against known chord qualities, and prefer the interpretation with the best root; fall back to slash-chord inversions (C/E) and, only if nothing matches, to an honest note-name list rather than fake degrees. Augmented-7 spells as aug7, never +7, precisely because a + had been the symptom of the old bug.

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 looks right.


Part 2 — The Logic bug class that bit us three times

Here’s the war story every plugin dev should internalize.

Resonote’s visualizer, note readout, and chord display all read their values from small std::atomic snapshots that were written inside processBlock. In the Standalone app that was fine — the audio device runs continuously, so processBlock fires forever and the snapshots are always fresh. Ship the same binary into Logic, stop the transport, turn a knob… and nothing moves. The curve freezes. The chord goes stale. It looked broken, and only in the DAW.

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

The root cause: Logic doesn’t call processBlock when the transport is stopped (and only does at all when audio flows through the track). Reading UI values from process-time snapshots couples your interface to the transport state. The fix is a one-liner in spirit — read the parameters directly on the message thread, not the process-time mirror — and I routed every UI getter through a single effectiveBandFreqHz() helper so the audio path and the display can never disagree.

It bit three times: first the band count stuck at 1, then the whole visualizer + chord readout went inert, then Lyriqueue’s transport readout froze for the same reason. “Works in Standalone, dead in Logic” is now a smell I recognize instantly.

The sibling gotcha: change gestures

While chasing the band-count issue I hit its cousin. Writing a parameter from code with setValueNotifyingHost works in Standalone and silently reverts in Logic — unless you bracket it in a gesture:

1
2
3
param->beginChangeGesture();
param->setValueNotifyingHost (newValue);
param->endChangeGesture();

Logic treats an un-gestured programmatic write as noise and rolls it back. Standalone doesn’t care. Same shape of bug, same “only in the DAW” signature.

The meta-lesson: Standalone is a liar of omission. It’s a great fast loop, but the DAW is the only ground truth. auval on every build, then quit-and-reopen Logic (it caches AU binaries in memory) before you trust anything.


Part 3 — Lyriqueue: lyrics that follow the playhead

Lyriqueue stores lyrics in the session and shows a teleprompter that follows the DAW’s playhead, cue by cue, in musical time. Cues are stored canonically as absolute PPQ (quarter-notes from song start) and rendered as Bar.Beat.Ticks using the host’s time signature. The active line is simply “the cued line with the greatest cue ≤ now.”

An early version showed impossible Bar.Beat.Ticks values; the fix was to do all arithmetic in PPQ and convert once at the edges, so beats and ticks always roll over musically.

It’s an aufx audio effect that doesn’t touch the audio — which produced a genuinely confusing support moment: on a silent track, Logic never processes it, so the playhead never arrives and following looks broken. The right move wasn’t code gymnastics; it was discoverability — a live transport readout plus a “waiting for transport” hint that appears only when the host has never processed the track (detected with a processBlock counter that never advances), and latches off the instant a block arrives so it never nags on a working track.

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

Lyriqueue’s teleprompter in Logic — the amber line at the bottom is exactly that “waiting for transport” hint.


Part 4 — WomackBus: the shared bus that almost wasn’t

This is the part I’m proudest of, because it looked done, passed its tests, and was quietly wrong.

I wanted the plugins to be a suite — to share musical context in one process. So I built WomackBus: a process-wide singleton with a client registry, a small blackboard, and change listeners; each plugin owns a RAII WomackBusClient that registers on construction and unregisters on destruction, and shows a tiny “Womack ×N” presence badge.

I built WomackCommon as a static library, linked it into each plugin, saw the badge show ×1, shipped it. The unit tests were green. The badge was… always ×1 across different plugins.

Why static linking silently broke it

A function-local-static singleton (WomackBus::get()) linked statically into two separate plugin bundles gets duplicated — each bundle carries its own copy. On macOS with the default two-level namespace, strong duplicate symbols are not coalesced across separately loaded bundles. So Resonote had one bus, Lyriqueue had another, and they never saw each other. Two instances of the same plugin shared (same image); two different plugins did not.

nm shows it plainly — every plugin binary defined the symbol (T), instead of importing it:

1
2
3
4
$ nm "Womack Resonote" | grep WomackBus3getEv
0000000000009ea0 (__TEXT,__text) external __ZN9WomackBus3getEv # T: its OWN copy
$ nm "Womack Lyriqueue" | grep WomackBus3getEv
0000000000009094 (__TEXT,__text) external __ZN9WomackBus3getEv # T: a DIFFERENT own copy

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

The fix: one shared, JUCE-free dylib

WomackCommon became a single shared dylib with an absolute install name under a shared directory (/Library/Application Support/Womack for installs; ~/Library/Application Support/Womack for dev). Every plugin records that one path, so at load time dyld maps one copy — one bus per process. Now nm shows the symbol imported (U) everywhere:

1
2
3
4
$ nm "Womack Resonote" | grep WomackBus3getEv
U __ZN9WomackBus3getEv # imported from the shared dylib
$ otool -L "Womack Resonote" | grep Womack
/Library/Application Support/Womack/libWomackCommon.dylib

There was one more trap I designed around from the start of the rewrite: the dylib’s ABI is deliberately JUCE-freestd::string, a plain std::uint32_t ARGB, plain structs. Why? Each plugin already embeds its own JUCE runtime. If the shared dylib also linked JUCE and I passed, say, a juce::Identifier or juce::String across the boundary, I’d be sharing objects that reference JUCE’s global string pool — but there’d be two JUCE runtimes with two pools. That’s a corruption waiting to happen. Keeping the bus boundary to std types means it links only libc++/libSystem (verifiable with otool -L) and can’t entangle the runtimes. Plugins convert juce::String ↔ std::string at the edge.

The installer ships the dylib as a required component (always installed), Developer-ID signed and notarized alongside the plugins.

The takeaway: green tests are necessary, not sufficient. The bus’s logic was always correct — N clients produce count N. What was wrong lived in the linking, a layer the unit tests couldn’t see. nm/otool were the tests that mattered here.


Part 5 — Where it’s going: shared musical context

With one real bus in place, the suite can finally share music, not just presence. The next piece (in progress) adds two things over the bus, both opt-in, both symmetric-peer:

  1. Shared key — turn on “Sync Key” and changing root/scale in one instance updates the others (with origin-id + value-compare + an “adopting” guard so it can’t echo forever).
  2. Frequency-signature awareness — each Resonote publishes the frequencies it occupies, and every instance can see the others’ 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 and another on an organ, and each can carve out its own spectral pocket instead of fighting for the same one. Awareness first; you carve manually; automatic avoidance is on the backlog.

Frequency-signature sharing: Resonote on piano and Resonote on organ each publish their signature to WomackBus and see the other's frequencies as ghosts with overlap warnings

A future Womack Ex Machina on the master bus can then become the leader that drives the whole suite — but that’s a later chapter.


How it’s built

A few process notes, because they mattered as much as the DSP:

  • Worktree per task. Every change happens on its own git worktree/branch; main only ever receives tested, confirmed work. Multiple build sessions never collide.
  • TDD on the pure parts. Note math, band ranges, chord naming, and the sync helpers are all pure and unit-tested (ResonoteTests, WomackBusTests) before they touch a filter or a UI.
  • Subagent-driven development. Larger features are executed task-by-task with a fresh implementer per task and a spec + code-quality review after each — plus a broad review before merge.
  • Validate relentlessly. auval on every build for all three plugins; nm/otool for the linking guarantees; quit-and-reopen Logic before trusting a change.

Development workflow: worktree and branch, failing test, implement, auval plus unit tests, task review, then merge to main with --no-ff


Closing

The fun of this project has been that the musical problems and the systems problems keep turning out to be the same problem viewed from two sides. A filter that “knows about notes” is equal parts pitch math and a fisheye you can drag. A “suite” is equal parts a shared key and a symbol that must be U and not T. And the recurring humbling lesson — from the scientific- notation cents, to the F +3 +5 +7 chord, to the bus that passed its tests while being wrong — is that looking right is not the same as being right. The DAW, the linker, and the music theory all get a vote.

More soon, once instances can carve out their own place in the mix.

— Womack Audio


Teaching a Prediction Loop to Distrust Itself

Ballpark Genius predicts MLB game outcomes — win probability, projected score, the “Pitcher Edge” and “Offense Edge” breakdowns you see on a game page. Behind that is a nightly champion/challenger loop: tune a candidate model, evaluate it against a fixed holdout set, and promote it only if it’s genuinely better. Over the course of about a week, I found and fixed three separate bugs in that loop — each one plausible-looking, each one silently defeating the whole point of having a holdout set in the first place.

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

The prediction surface this loop feeds — a real game, produced by the champion model after all three fixes below.

Bug 1: the eval set that stopped growing

The gate that decides whether to promote a new model runs entirely against games flagged held_out = true. That flag is a persisted boolean, and it was only ever set by a one-off backfill script — the daily import path that adds new games as the season progresses never set it. 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 training set instead. Two eval reports run a month apart came back byte-identical — same 120 games, same Brier score to four decimal places — because the loop was judging every new candidate against a yardstick that hadn’t moved in a month.

The fix was to stop treating “mark the holdout set” as a manual, one-time step: importGames now assigns heldOut at insert time for every game, using the same holdout-selection rule the backfill script used, so the eval set grows deterministically with every import and can’t drift out of sync again. Re-running the backfill once to catch the 67 games that had already leaked brought the holdout set current — 187 games instead of 120.

Bug 2: a fixed threshold that noise could clear

With a live holdout set, the next problem was the promotion gate itself. It compared aggregate composite scores against a flat epsilon = 0.005 — if the candidate beat the champion by more than 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 was mostly reacting to noise: on at least one occasion it treated a +0.025 swing that was almost certainly sampling variance as a genuine, promotion-worthy gain. And because the epsilon was fixed, a growing holdout set — which should make the gate more discerning as more data comes in — never actually lowered the bar for what counted as a real signal.

The fix replaced the flat threshold with a paired one-sided significance test on per-game composite scores: compute the champion-minus-candidate delta on the same holdout games for both models, and promote only when the lower bound of a 95% confidence interval on that delta clears zero. As the holdout set grows, the standard error shrinks, so smaller real gains become detectable over time — the gate gets sharper with more data instead of staying flat forever. A dry run against the live 180-game holdout immediately started returning honest reasons instead of noisy verdicts: a worse candidate failed on a negative lower bound; a directionally-better model (a real +0.0010 edge, ±0.0066) correctly failed as not yet statistically significant rather than being promoted on what was still mostly noise.

Bug 3: the tuner that optimized for the wrong exam

Fixing the gate exposed the last problem. The coordinate-descent tuner that produces each candidate ran to convergence minimizing composite score on a single ~785-game walk-forward sample, then got graded on the separate holdout set. That’s an overfitting setup by construction: 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 the significance gate every single time.

The fix added an explicit shrinkage-to-champion penalty to the tuning objective — composite + λ · penalty, where the penalty is the range-normalized distance from the current champion’s weights — and chose λ by k-fold cross-validation rather than by hand: for each candidate λ, 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 genuinely generalizing tune is never blocked from winning outright.

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 the data doesn’t support moving off the champion at all. That microscopic +0.0003 edge also correctly fails a companion fix from the same pass: a minimum-effect-size floor on the significance gate, added after noticing that heavy shrinkage makes a candidate converge toward the champion so closely that its paired variance collapses too, letting a statistically “significant” but practically meaningless edge slip through a bare > 0 check. Requiring the confidence interval to clear a real minimum, not just zero, closed that gap.

Why the order of these fixes mattered

None of the three bugs were individually exotic — a flag that never got set, a threshold that didn’t account for sample noise, an objective that didn’t match its own exam. What made this a multi-week chase rather than a single fix is that each one made the next one look like the real problem. A frozen holdout set makes a noisy gate look stable, because it’s comparing against 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 and the gate was honestly measuring uncertainty did the tuner’s overfitting become visible as its own distinct bug rather than getting attributed to “the gate is too strict” or “the holdout is too small.”

The loop is mechanically sound now — it evaluates on a live holdout, requires statistically real improvement at a real effect size, and tunes candidates against an objective that generalizes instead of memorizing. heuristic_v9 is already near-optimal for its current feature set, and neither a re-tuned version of itself nor a logistic-regression challenger beats it meaningfully right 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 a loop-machinery problem, and proving that distinction was most of the actual work.

— James


Teaching Claude Not to FLUSHALL My Redis

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 redis-cli FLUSHALL is a reasonable way to test a cache bug in a worktree that isn’t prod.

So I wrote a PreToolUse hook. It sits in front of every Bash call and denies anything that looks destructive against those two: redis-cli DEL/FLUSHALL/SET/HSET/EXPIRE/RENAME, and psql DROP/DELETE/TRUNCATE/UPDATE/anything with ALTER ... DROP|RENAME. 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.

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.

The honest reason I built this w/ a hook instead of a note in CLAUDE.md: 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.”


The AVG Leaderboard Had a 3-At-Bat Batting Champion

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.

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. getStatsLeaders in the API client accepts minAtBats/minInningsPitched, the route maps them, the SQL applies them as AND at_bats >= N. The one place that was supposed to send them, a single call site in the leaderboards page, just didn’t. It only passed minGames.

One missing line, in other words, was the entire distance between “AVG leaderboard” and “who’s had the luckiest 3 at-bats this week.”

Ballpark Genius batting average leaderboard after the fix, led by Yordan Alvarez at .322 across 117 games played

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.


The Blue Flash

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.

The overlay meant to cover stale rows while new ones loaded used bg-card for its background and bg-muted 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.

Ballpark Genius home run leaderboard, the current polished state the blue flash used to interrupt on every stat switch

The fix was to stop covering the content and dim it instead: drop the skeleton overlay div entirely, set the real rows to opacity: 0.4 while fetching, block clicks with pointerEvents: none until the new data lands. Real colors stay real colors, just quieter for a moment. Skeleton rows, the animate-pulse placeholders, are still there for a true cold load with nothing cached yet. They just don’t fire on every category switch anymore.

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.

Small bug. Embarrassingly satisfying to finally kill.


A Week of CI Whack-a-Mole

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.

It started with GitHub Actions deprecating Node 20 on its runners, a warning on every job, while all four CI jobs still hardcoded node-version: 20 against a local/prod toolchain already on Node 24 via .nvmrc. Switched every setup-node step to node-version-file: .nvmrc so CI can’t drift from the one file that’s supposed to be the source of truth, again.

That unstuck the next one: husky‘s prepare script runs on every npm install, including Vercel’s production install, where there’s no .git directory and husky exits non-zero. Vercel deploys had been failing on that alone. Gated it behind a .git existence check.

Past that, tsc failed with about 250 errors, all downstream of one thing: nobody had run prisma generate before it. Vercel caches node_modules and skips regeneration by default. GitHub CI only worked because its build job ran prisma generate explicitly first. Added it as a postinstall and prepended it to build, so the client exists before tsc touches it in any environment. prisma generate only reads schema.prisma, no DATABASE_URL required, so it’s safe in a DB-less build too.

Then the deployed function crashed at import: winston’s File transport calls mkdirSync('logs') on construction, and Vercel’s filesystem is read-only. Gated the file transports on a serverless check (VERCEL/AWS_LAMBDA_FUNCTION_NAME) and wrapped construction in try/catch. Console transport is enough there, Vercel captures stdout anyway.

None of this makes the API actually happy running on Vercel, that’s a persistent Fastify server with node-cron 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.”