<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://judes.club/feed.xml" rel="self" type="application/atom+xml" /><link href="https://judes.club/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-22T00:30:10-04:00</updated><id>https://judes.club/feed.xml</id><title type="html">Alec Jude Wilson</title><subtitle>Alec Jude Wilson is a software engineer in New York building web and mobile software. Writing on engineering and craft, selected projects, and photography.</subtitle><author><name>Alec Jude Wilson</name><email>hi@judes.club</email></author><entry><title type="html">9 months in new york</title><link href="https://judes.club/writing/nine-months-in-new-york/" rel="alternate" type="text/html" title="9 months in new york" /><published>2026-10-22T00:00:00-04:00</published><updated>2026-10-22T00:00:00-04:00</updated><id>https://judes.club/writing/nine-months-in-new-york</id><content type="html" xml:base="https://judes.club/writing/nine-months-in-new-york/"><![CDATA[<p><img src="/assets/images/writing/nyc-cover.jpeg" alt="The Empire State Building lit gold in the fog" /></p>

<p>its been 9 months so heres some stuff</p>

<h2 id="people">people</h2>

<ul>
  <li>ive grown alot. like im just a pretty diffrent person than who showed up here 9 months ago</li>
  <li>met so many new people its kind of crazy</li>
  <li>connections mean everything honestly. pretty much every good thing thats happened here came from a person, someone introducing me to someone or inviting me somewhere or just texting back</li>
</ul>

<h2 id="work">work</h2>

<ul>
  <li>built alot of stuff. some shipped some was just for fun some will never see the light of day and thats fine</li>
  <li>learned alot technically too mostly by breaking things and then having to fix them</li>
  <li>almost 2 years as a software engineer now so im almost not a junior anymore which is wierd to think about</li>
</ul>

<h2 id="the-house">the house</h2>

<ul>
  <li>i miss my dog. every day. petting other peoples dogs helps a little but not really</li>
  <li>my house plants are my favorite thing. they dont talk back and they dont need walks and when one grows a new leaf i feel like i did something</li>
  <li>get out of the house. this ones mostly a note to myself. the house is nice and the plants are nice but theres a whole state out there</li>
</ul>

<h2 id="back-home">back home</h2>

<ul>
  <li>i still think about home alot. like more than i thought i would</li>
  <li>i think about past decisions too. the big ones and the tiny ones that didnt feel like anything at the time</li>
  <li>and all the diffrent ways my life couldve gone. if i said no to moving here or picked a diffrent job or stayed where i was. theres probably a version of me doing totally fine somewhere else and i think about him sometimes</li>
</ul>

<h2 id="whats-next">whats next</h2>

<ul>
  <li>still not sure where im headed. maybe its here maybe its not here. maybe its this job or another job. maybe its work maybe its world travel</li>
  <li>and im trying to be ok with that. 9 months ago i didnt know id be here either</li>
</ul>]]></content><author><name>Alec Jude Wilson</name><email>hi@judes.club</email></author><category term="reflection" /><summary type="html"><![CDATA[9 months of new people and alot of code and missing my dog and buying too many plants]]></summary></entry><entry><title type="html">Nobody Prays in a Warm House</title><link href="https://judes.club/writing/first-world-country-dilemma/" rel="alternate" type="text/html" title="Nobody Prays in a Warm House" /><published>2026-08-13T00:00:00-04:00</published><updated>2026-08-13T00:00:00-04:00</updated><id>https://judes.club/writing/first-world-country-dilemma</id><content type="html" xml:base="https://judes.club/writing/first-world-country-dilemma/"><![CDATA[<h2 id="background">Background</h2>

<p>I am a very statistical person. I have always loved numbers since I was a kid. Every school project, many personal projects, and many observations I have resolve around numbers, statistics, and patterns. I was also raised in a household that had several very successful businesses but was also a very religious house. These three things combined created a couple unique interests of mine.</p>

<h3 id="interest-1-religious-statistics">Interest 1: Religious Statistics</h3>

<p>I loved reading about religion. Any type, any beliefs, any culture, any location. Seeing the fall of Christianity in Europe and Americas while seeing the rise of it and Islam in Africa. Seeing countries that always integrated religion into government slowly phase it out and become “woke.” These things interested me.</p>

<h3 id="interest-2-economic-statistics">Interest 2: Economic Statistics</h3>

<p>Taking religion out of the picture, I was still interested in finances, numbers, and world economics. I think this one is a little less unique. Nonetheless, I was still interested in researching things like Chinas development on capitalistic zones, seeing how the US govt debt is actually used in a beneficial extent. Seeing how money flows in startups and through VC and investment firms, and more recently researching liquidity problems in the stock market.</p>

<h2 id="observations">Observations</h2>

<p>One of the observations I noticed first in my life was the decline or stagnation of religion in first world countries and the continued high success of religion in povershed countries.</p>

<pre><code class="language-mermaid">%% caption: The poorest countries on this list are the most religious.
xychart-beta
    title "Religion important in daily life, 2024-25"
    x-axis [Niger, Bangladesh, Malawi, Uganda, Kenya, Nigeria, Mexico, Portugal, Chile, US, Poland, Germany, UK, Japan, Sweden, Norway]
    y-axis "% of adults" 0 --&gt; 100
    bar [100, 99, 99, 98, 95, 89, 67, 59, 57, 51, 47, 34, 30, 21, 21, 17]
</code></pre>

<pre><code class="language-mermaid">%% caption: Poorer countries stay even or slightly grow compared to significant decreases in first world countries.
xychart-beta
    title "Change in religiosity, 2010s (directional)"
    x-axis [Kenya, Nigeria, Malawi, Ethiopia, US, Australia, Chile, Uruguay]
    y-axis "change in % points" -20 --&gt; 10
    bar [3, 2, 2, 1, -13, -17, -15, -14]
</code></pre>

<pre><code class="language-mermaid">%% caption: We see most first world countries experience a slight movement but are already considerably low compared to their poorer counterparts.
xychart-beta
    title "Religion important in daily life: 2009 vs 2024-25"
    x-axis [Bangladesh, Niger, Senegal, Malawi, US, UK, France, Japan, Sweden]
    y-axis "% of adults" 0 --&gt; 100
    bar [99, 100, 99, 99, 65, 27, 30, 24, 17]
    bar [99, 100, 99, 99, 49, 30, 25, 21, 21]
</code></pre>

<p>The second observation came a lot later and I think it is related. Birth rates do the same thing.</p>

<pre><code class="language-mermaid">%% caption: Same countries in the same order as the first chart. The poorer ones have a lot more kids.
xychart-beta
    title "Total fertility rate, 2023"
    x-axis [Niger, Bangladesh, Malawi, Uganda, Kenya, Nigeria, Mexico, Portugal, Chile, US, Poland, Germany, UK, Japan, Sweden, Norway]
    y-axis "births per woman" 0 --&gt; 7
    bar [6.6, 2.1, 3.8, 4.4, 3.2, 4.8, 1.8, 1.4, 1.2, 1.6, 1.2, 1.4, 1.4, 1.2, 1.4, 1.4]
</code></pre>

<p>This is the same list of countries in the same order as the first chart. I did not reorder anything. Niger is the highest on both. Japan is the lowest on both. Once you get to Mexico it flattens out into the same band between one and two kids, which is right around where the religion numbers flatten out too.</p>

<p>It is not perfect. Bangladesh is very religious and is already down to about two children per woman, so the two things can clearly come apart. Nigeria goes the other way. But two out of sixteen does not really change the shape of it.</p>

<p>Correlation is not causation and I am not going to act like it is. Being religious does not make you have kids and having kids does not make you religious. What I think is worth asking is why two totally different behaviors drop off at about the same income level, in the same decade, in close to the same order.</p>

<h2 id="the-connection">The Connection</h2>

<p>I think the connection is that both of them used to be useful.</p>

<p>Religion cost you time and money and a list of things you were not allowed to do. What you got back was a community that showed up when you were sick, a reason to keep going when things were bad, and a set of rules you did not have to figure out yourself. For most of history that was worth it.</p>

<p>Children cost more. What you got back was labor and old age insurance. Somebody to take care of you at sixty, and enough of them that it still worked out if one or two did not make it. That was worth it too.</p>

<p>Neither one of those is worth it in a first world country anymore, at least not in the same way. I do not think that is people getting worse or losing their morals. I think it is people responding to their situation, which is what people always do.</p>

<h2 id="why-this-happens">Why This Happens</h2>

<p>Take everything religion and children used to provide and look at what a first world country provides instead.</p>

<p>If you get sick you go to a hospital. If you lose your job there is unemployment and there is savings. If you get old there is social security and a 401k, and you do not need a son for any of it. If you are lonely there is a phone with more people in it than any church ever had. If you need rules there is a legal system. If you need something to organize your life around there is a career.</p>

<p>All of it is covered now. Usually worse and usually more expensively, but covered well enough that you never really hit the point where you would go looking for something else.</p>

<p>This is where the title comes from. Nobody prays in a warm house. I do not mean people in warm houses are smarter or that they figured something out. Praying is what you do when the house is cold and you are out of options. Take away the cold and the praying goes with it. Kids are the same. Nobody in Niger loves their kids more than somebody in Norway does. They are dealing with a problem that Norway already solved a different way.</p>

<h2 id="the-dilemma">The Dilemma</h2>

<p>The part I keep coming back to is that the things a country drops on the way to being comfortable are the same things that got it comfortable.</p>

<p>You do not get to be a first world country without a lot of people having a lot of kids, and without enough shared belief in something to build things that outlast the people building them. Then the country succeeds, and a generation or two later the population looks at both of those and decides, correctly, that it does not need them anymore.</p>

<p>The bill for that shows up later and it shows up as arithmetic.</p>

<pre><code class="language-mermaid">%% caption: How many working age adults there are for every person over 65. The 2050 numbers are projections but they are mostly already set.
xychart-beta
    title "Workers per retiree, 2025 vs 2050 (projected)"
    x-axis [US, UK, Germany, Italy, Japan, "South Korea"]
    y-axis "adults 20-64 per person 65+" 0 --&gt; 4
    bar [3.1, 2.9, 2.6, 2.2, 1.8, 3.2]
    bar [2.3, 2.2, 1.8, 1.4, 1.3, 1.1]
</code></pre>

<p>South Korea is the one I would look at. It has the best ratio on that chart right now and the worst one twenty five years from now. That is not a prediction about how people are going to behave. Everybody who will be working in 2050 has already been born, so the top number is set. The pension system, the healthcare system, and most of the tax base of every country on that chart are running on a number nobody can change at this point.</p>

<h2 id="trying-to-fix-it">Trying To Fix It</h2>

<p>The obvious answer is that the government should pay people to have kids, and a lot of countries have tried this.</p>

<p>South Korea has spent somewhere over $200 billion on it since 2006. In that same window its fertility rate went from about 1.1 to about 0.7, which is the lowest number ever recorded anywhere. Hungary spends around five percent of GDP on family policy, which is more than it spends on its military, and it got a bump from about 1.2 to about 1.5 before it started sliding back. France has the most generous family policy in Europe and is still under replacement.</p>

<p>So the money does not seem to be the issue. I think you cannot pay somebody back into a behavior that only existed because it was necessary. Needing the subsidy is itself evidence that the necessity is gone.</p>

<p>Religion is the same. Countries have tried putting it back in the schools and back in the constitution and it does not move the numbers much, because what filled the churches was never a law.</p>

<h2 id="what-i-think">What I Think</h2>

<p>I am not writing this to say anybody should go back to church or have six kids. I do not think you can talk somebody into either one, and most of this post is my explanation for why.</p>

<p>What I think is that comfort has a cost and the cost is not money. The cost is that the behaviors that built the comfort stop making sense to the people who grow up inside of it. Every generation in the warm house is a little more confused about why anybody bothered building it, and a little worse at building another one.</p>

<p>I do not have a solution and I am pretty suspicious of anyone who says they do, especially since the countries trying the hardest have the worst numbers. What I have is the pattern, and the pattern is very consistent. Income goes up, religion goes down, births go down, and the difference gets covered with immigration and debt for however long that works.</p>

<p>I should also say that I am part of this, which is probably worth admitting after spending five charts pointing at other people.</p>

<p>I still follow religion. I was raised in it and I kept it and I do not really see that changing. But I am not planning on having kids. I have thought about it a decent amount and the honest answer is that none of the old reasons apply to me. I am not going to need them for work, I am not going to need them to take care of me when I am older, and everything else I would want out of it I can get some other way. That is the exact argument I just spent this whole post making. Knowing the argument does not seem to make me any less of an example of it.</p>

<p>So I am sitting somewhere in the middle of my own chart. I kept the cheap one and dropped the expensive one, which is more or less what Bangladesh is doing, just for completely different reasons. I think that is probably where a lot of people end up. Religion is a lot easier to hold onto than kids are because it does not ask for the same thing.</p>

<p>I am not sure if any of this is a problem or if it is just what happens. Probably some of both. But it is the most consistent thing I have found in all the years I have been looking at this stuff, and I have not seen anybody give a good answer for it, including me.</p>]]></content><author><name>Alec Jude Wilson</name><email>hi@judes.club</email></author><category term="first world" /><category term="religion" /><category term="fertility" /><category term="children" /><category term="economic" /><summary type="html"><![CDATA[when comfort becomes the norm, the very things we used to use create comfort get left behind]]></summary></entry><entry><title type="html">The market won the argument: building a baseball model</title><link href="https://judes.club/writing/building-statball/" rel="alternate" type="text/html" title="The market won the argument: building a baseball model" /><published>2026-07-28T00:00:00-04:00</published><updated>2026-07-28T00:00:00-04:00</updated><id>https://judes.club/writing/building-statball</id><content type="html" xml:base="https://judes.club/writing/building-statball/"><![CDATA[<p><a href="https://statball.net">Statball</a> started as a nostalgia project. I wanted a
vintage newspaper sports page on the web — cream newsprint, condensed marquee
type, an LED scoreboard — that could replay baseball’s most legendary finishes
one play at a time, with a win-probability needle swinging on every pitch.</p>

<p>About a hundred commits later, the same repository contained a 1.3 GB
play-by-play database, an ML discovery engine with a false-discovery-rate
correction, a pre-registered prediction protocol, and a cron job placing
real-money orders on Kalshi with my account.</p>

<p>The almanac is still there. But the honest summary of the project is that I
built a toy, the toy demanded data, the data demanded a model, and the model
demanded to be tested against a market — which promptly taught it some
humility. This is the build log, including the parts that didn’t work, because
those turned out to be the point.</p>

<h2 id="a-newspaper-that-replays-games">A newspaper that replays games</h2>

<p>Version one shipped with six games, each hand-reconstructed from public
play-by-play: the ball through Buckner’s legs in ‘86, Dave Roberts stealing
second in the ‘04 ALCS, Gibson off Eckersley, Freese twice down to his final
strike, Mazeroski’s walk-off, Fisk waving it fair. You step through a game
pitch by pitch, or let it auto-play, while a retro scoreboard tracks the
score, inning, outs, and baserunners, and a win-probability chart scrubs along
underneath. Drag across the chart and the game follows your cursor.</p>

<p>Two early decisions aged well. First, no charting library — the
win-probability chart is hand-drawn SVG, which meant I owned every pixel when
the design later got opinionated. Second, the win probability is <em>derived</em>,
not fabricated. A small self-contained model combines the classic base-out run
expectancy table (RE24) with a run estimate for the innings still to come, and
reads the home team’s win probability off a normal approximation of the run
differential. It’s an approximation and the site says so, but every swing in
the chart traces back to a real change in game state rather than a designer’s
sense of drama.</p>

<p>Around the replays grew the almanac furniture: an On This Day page, and the
Oddities file — a shelf for unassisted triple plays, immaculate innings, and
the hidden-ball trick.</p>

<h2 id="then-retrosheet-happened">Then Retrosheet happened</h2>

<p>Six games is a demo. Baseball has a complete, public, volunteer-maintained
record of essentially every game ever played, kept by
<a href="https://www.retrosheet.org">Retrosheet</a>, and once you know that, a
hand-curated archive starts to feel like a placeholder.</p>

<p>So the project grew an ingest pipeline: Retrosheet’s event files, parsed with
the Chadwick tools, loaded into SQLite. The game-log archive covers every game
since 1901 down to the inning; the play-by-play era adds about 14.7 million
plate appearances. The events database alone is 1.3 GB, which is a funny
thing to find inside what is nominally a Next.js side project.</p>

<p>The archive turned the site from a museum into an instrument. A
<a href="https://statball.net/scorigami">Scorigami</a> explorer (every final score that
has ever happened, and when it happened first), team pages for every
franchise, an era toggle that follows you around the site, and replays rebuilt
out-by-out from the real sequence of events — eleven of them now, not six.</p>

<h2 id="the-lab-subtract-identity-until-only-the-stories-are-left">The Lab: subtract identity until only the stories are left</h2>

<p>With every game since 1901 in a queryable database, the tempting move is to go
anomaly fishing: scan everything, surface whatever looks weird, write it up.
The problem is that a century of baseball contains an enormous amount of
weirdness by pure chance, and a scanner with no discipline becomes a machine
for laundering noise into narrative.</p>

<p>The <a href="https://statball.net/lab">Statball Lab</a> is my attempt to do it with
discipline. The core trick is what I’d call identity-free residuals. For each
target stat — runs, wins, home runs, strikeouts, walks, errors — a
gradient-boosted model absorbs era and context: year, month, day or night,
home or road. Then iterative backfitting removes each franchise’s, ballpark’s,
and opponent’s long-run character. Team and park are deliberately <em>not</em>
features of the model, because a tree ensemble would happily learn year×team
interactions — which are exactly the deviations being hunted. Whatever is left
in a residual cannot be explained by when the game happened, where it
happened, or who was playing. If a residual still shows structure, something
actually happened.</p>

<p>On top of those residuals sit thirteen analysis families: interaction
subgroups (about twenty thousand of them), head-to-head matchup residuals,
parsed line scores, schedule and fatigue effects, season-vector outliers via
IsolationForest, year-over-year persistence, streak tests, detrended
correlations, regime change-points, and so on. Everything that comes out the
other side has passed Benjamini–Hochberg FDR control, batched permutation
tests, and bootstrap confidence intervals. The engine writes its findings —
with generated articles and chart specs — straight into the site.</p>

<p>The satisfying moments are when the machine rediscovers history blind. The
schedule scanner, knowing nothing but dates and times, found the blue-law
Sundays when Pennsylvania teams couldn’t play at home, and the decades of
missing night games at Wrigley. The outlier family, working purely from the
geometry of standardized season vectors, flagged Connie Mack’s fire-sale
Athletics as one of the largest one-winter identity shifts in the sport’s
history. And the innings family established that “down one run with three
outs left” has been worth roughly the same slim chance for a century — a
constant, stable across every era it can be measured in.</p>

<h2 id="the-oracle-predicting-tonight-instead-of-explaining-1975">The Oracle: predicting tonight instead of explaining 1975</h2>

<p>Explaining the past is safe. Every claim the Lab makes is checkable but also
unfalsifiable in the way that matters: nothing is at stake. So the next step
was a <a href="https://statball.net/model">prediction system</a> for real games — tonight’s
slate, published before first pitch, scored after the last out.</p>

<p>The design constraint I cared most about was that the system couldn’t be
allowed to flatter itself. So the protocol is pre-registered and frozen: the
model’s coefficients were fit on data before May, the feature set is fixed for
the season, and the evaluation code is treated as untouchable — editing it
would be rewriting the yardstick mid-experiment. Every pick lands in a
<a href="https://statball.net/record">public ledger</a>, scored game by game. When a
model change looks promising, it goes into a walk-forward research harness,
not into the live season.</p>

<h2 id="the-100-experiment">The $100 experiment</h2>

<p>Then came the obvious question: is any of this worth money? Kalshi runs
prediction markets on MLB games, which means every night there’s a real price
to disagree with.</p>

<p>The test was a $100 experiment replayed at real Kalshi prices over the
May-to-early-July window, under the frozen protocol, with a few
pre-registered strategies. The headline result was humbling, and it’s the
single most important number the project produced: over that window, the
Kalshi close was better calibrated than the model. Market Brier score 0.2459,
model 0.2619. The market, aggregating everyone’s information, is simply
sharper on average than my frozen regression.</p>

<p>But the experiment had a second finding. The only strategy line that made
real money was the <em>strong-edge</em> line: bet only when the model disagrees with
the price by five or more points, sized at quarter-Kelly. That line turned
$100 into $141.85. The looser line that bet every two-point disagreement
roughly broke even at $103.99 — and that’s before you get rigorous about
fees.</p>

<p>Read together, those two results say something specific: you don’t profit by
being smarter than the market on average, because you’re not. If there’s
profit at all, it comes from the rare nights when the model and the market
disagree <em>a lot</em>, sized fractionally, with costs priced in. Everything else
is donating the spread.</p>

<h2 id="rebuilding-the-trader-around-a-humbling-result">Rebuilding the trader around a humbling result</h2>

<p>The first live trader I wrote did not follow that lesson, and the design
review that fixed it was essentially a list of ways I was quietly lighting
money on fire.</p>

<p>The gate was confidence, not price: any pick the model liked at 62%+ got bet,
even when Kalshi priced the same team <em>higher</em> than the model — a
negative-expected-value trade against a market we had just measured as
sharper. There was a 3% “base stake” on edgeless picks, which converted
spread plus fees into a guaranteed drip out of the account, because Kelly on
a zero edge is zero. And the fee math was missing entirely: Kalshi’s taker
fee is 0.07·P·(1−P) per contract, about 1.6 cents at mid prices — roughly
2.5% of stake, which is <em>larger than most of the edges being bet</em>.</p>

<p>The rebuilt trader has one gate: net edge. A signal exists only when the
model’s probability beats the ask <em>plus the fee</em> by at least five points —
the pre-registered strong line, now net of costs — and only when the book is
real (a live bid, a sane ask, a spread of six cents or less; a wide or
one-sided book means the ask is stale, not cheap). Stake is quarter-Kelly on
fee-adjusted odds, capped at 10% per position and 25% per day. No base bet.
Each run cancels any resting order whose game has started or whose edge has
evaporated. Model confidence is no longer consulted at all, because a 62%
pick priced at 64 cents carries no information the price doesn’t already
contain.</p>

<p>Operationally the whole thing is one Docker image on Railway with two jobs:
the same container that serves the site becomes the cron trader when a flag
is set. The trader defaults to dry-run; real orders require explicit intent.
The live account — the <a href="https://statball.net/stake">Stake</a> — is published on
the site, wallet balance and all, because a track record you can edit isn’t a
track record.</p>

<h2 id="the-graveyard">The graveyard</h2>

<p>The part of the project I’m proudest of is the pile of features that don’t
exist.</p>

<p>Model improvements go through a walk-forward harness: fit on the past,
predict the future, slide the window, repeat across decades of seasons. No
peeking. The harness has now rejected, in order: lineup rating channels,
bullpen rating channels, platoon structure, park-specific home-field
advantage, starter rest days, and a seed-ensemble scheme. Built, tested,
flat. Every one of them is a real effect you can find in-sample; none of them
survived out-of-sample at game granularity, where a 60/40 edge is enormous
and most true signals are already soaked into the base rates the model
carries anyway.</p>

<p>Each of those features would have made the model <em>feel</em> smarter. The
walk-forward harness is what stands between “feels smarter” and the live
account, and its job is to say no. Meanwhile the live system tracks the two
leading indicators that actually predict whether this ends well: closing line
value — whether the market moves toward our price after we bet — and the
slowly accumulating forward sample.</p>

<p>A note on process: the four-day sprint from scaffold to trading engine was
pair-work with Claude Code — it did the typing and a lot of the arguing, I
made the calls. What kept the speed from becoming recklessness wasn’t either
of us being careful in the moment; it was that every model idea, no matter
who proposed it, had to survive the same frozen protocol and the same
walk-forward gauntlet. Process beats vigilance.</p>

<h2 id="what-i-actually-built">What I actually built</h2>

<p>The about page says it in one line: <em>Statball builds baseball models. The
models are proprietary; the results are not.</em> The site has grown the full
costume of a quant firm — a research page, a published model card, a live
trading desk, even a careers page with application questions — and the
costume is doing real work, because the firm framing imposes the right
obligations. Firms publish track records. Firms eat their fees. Firms don’t
retrain the model mid-season because last week felt bad.</p>

<p>Underneath the costume, what I actually built is a discipline machine. The
almanac was the fun part. The archive made claims checkable. The Lab added
multiple-testing hygiene so the archive couldn’t be strip-mined for fake
stories. The frozen protocol made predictions falsifiable. The market priced
them, the fees taxed them, and the public ledger made the outcome
un-editable. Every stage exists to make it harder for the project to lie to
me.</p>

<p>The design document for the trader ends with an honesty clause, and it
belongs at the end of this post too: no configuration of this system is
guaranteed to print money. The market’s aggregate calibration beats the
model’s. Profit, if any, comes from rare large disagreements, sized
fractionally, with costs priced in.</p>

<p>The forward ledger is the arbiter. It’s <a href="https://statball.net/stake">public</a>.</p>]]></content><author><name>Alec Jude Wilson</name><email>hi@judes.club</email></author><category term="baseball" /><category term="data" /><category term="prediction-markets" /><category term="engineering" /><summary type="html"><![CDATA[Statball started as a nostalgia project: a cream-newsprint almanac that replays famous games pitch by pitch. A hundred commits later it had ingested every game since 1901, built its own discovery engine, and was betting real money on Kalshi. It did not go the way the model hoped. This is the build log.]]></summary></entry><entry><title type="html">Cracking the WHOOP 5.0 over Bluetooth</title><link href="https://judes.club/writing/cracking-the-whoop-5-bluetooth-protocol/" rel="alternate" type="text/html" title="Cracking the WHOOP 5.0 over Bluetooth" /><published>2026-06-01T00:00:00-04:00</published><updated>2026-06-01T00:00:00-04:00</updated><id>https://judes.club/writing/cracking-the-whoop-5-bluetooth-protocol</id><content type="html" xml:base="https://judes.club/writing/cracking-the-whoop-5-bluetooth-protocol/"><![CDATA[<p><img src="/assets/images/writing/whoop5-cover.svg" alt="Cracking the WHOOP 5.0 over Bluetooth: a decoded frame of hex bytes with the start-of-frame, header CRC, inner payload, and CRC-32 marked." /></p>

<style>
.wd-demo{display:flex;align-items:center;gap:1.1rem;margin:1.8rem 0 2.6rem;padding:1.15rem 1.35rem;border:1px solid var(--line);border-radius:14px;background:rgba(127,156,209,0.045);text-decoration:none!important;transition:border-color .18s ease,background .18s ease,transform .18s ease;}
.wd-demo:hover{border-color:var(--accent);background:rgba(127,156,209,0.1);transform:translateY(-1px);}
.wd-demo-icon{flex:0 0 auto;width:48px;height:48px;border-radius:50%;display:grid;place-items:center;background:var(--accent);box-shadow:0 0 0 6px rgba(127,156,209,0.12);}
.wd-demo-icon svg{width:18px;height:18px;fill:#0d0e10;margin-left:3px;}
.wd-demo-body{flex:1 1 auto;display:flex;flex-direction:column;gap:0.16rem;min-width:0;}
.wd-demo-k{font-family:var(--sans);font-size:0.68rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);}
.wd-demo-t{font-family:var(--sans);font-weight:500;font-size:1.1rem;color:var(--text);line-height:1.25;}
.wd-demo-s{font-family:var(--sans);font-size:0.9rem;color:var(--muted);line-height:1.45;}
.wd-demo-arrow{flex:0 0 auto;font-size:1.35rem;color:var(--accent);transition:transform .18s ease;}
.wd-demo:hover .wd-demo-arrow{transform:translateX(4px);}
@media(max-width:540px){.wd-demo-s{display:none;}.wd-demo{gap:0.9rem;padding:1rem 1.1rem;}}
</style>

<p><a class="wd-demo" href="/experiments/whoop5/">
  <span class="wd-demo-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"></path></svg></span>
  <span class="wd-demo-body">
    <span class="wd-demo-k">Interactive</span>
    <span class="wd-demo-t">Play with the protocol in your browser</span>
    <span class="wd-demo-s">Wake a simulated band up, drag its heart rate, tilt it, and watch the real Bluetooth bytes change.</span>
  </span>
  <span class="wd-demo-arrow" aria-hidden="true">→</span>
</a></p>

<p>I own a WHOOP band. It reads my heart rate about a hundred times a second, tracks how I sleep, and decides every morning whether I’m “recovered.” All of that lives in WHOOP’s app and WHOOP’s cloud, behind a subscription. The data is about my own body and I can barely touch it.</p>

<p>Someone had already fixed this for the older band. John Middleton built <a href="https://github.com/johnmiddleton12/my-whoop">my-whoop</a>, an open-source, local-first client that reads a WHOOP 4.0 over Bluetooth and keeps everything on your own hardware: the app, the decoding, the storage, all of it.<sup id="fnref:priorart" role="doc-noteref"><a href="#fn:priorart" class="footnote" rel="footnote">1</a></sup> I have a 5.0. When I pointed his client at it, every channel went dark. Getting it talking again, and getting the biometric stream out, is the story. The 4.0 groundwork is John’s. The 5.0 work below is mine, built on top of his.</p>

<p>This is the technical writeup. If you’d rather poke at the protocol than read about it, I built an <a href="/experiments/whoop5/">interactive playground</a> where you can decode and build real 5.0 frames in the browser, take apart a biometric packet, and watch the checksums update as you type. The full spec lives there too.</p>

<h2 id="a-quick-bluetooth-primer">A quick Bluetooth primer</h2>

<p>Skip this if you’ve written BLE code before. If you haven’t, four words show up constantly below and they all map onto things you already know from the web.</p>

<p>A Bluetooth Low Energy device acts like a tiny server. It has no URLs. What it has instead are <strong>characteristics</strong>, which are closer to individual fields you can hit. Some you read. Some you write to. A few you can subscribe to, and those are called <strong>notify</strong> characteristics. Subscribe to one and the device pushes you a fresh value every time it has a new one, no polling involved, which is how a band feeds you a heart rate twice a second without you asking each time. Characteristics that belong together get bundled into a <strong>service</strong> with its own long UUID. The spec has a name for this whole scheme, GATT, and that’s genuinely the last time you need to think about that acronym.</p>

<p>The word that actually matters here is <strong>bond</strong>. Before a phone can touch most of a band’s characteristics, the two have to pair and then bond, which is roughly Bluetooth’s version of a TLS handshake with a “remember this device next time” step attached. And pairing has two strengths, which turns out to be the whole story of this post. The weak one is called “just-works”: it brings up an encrypted link without either side proving who it is, the way HTTPS behaves if you switch certificate checking off. Encrypted, but you can’t be sure who’s on the other end. The strong one is <strong>authenticated</strong>, and it also defends against someone wedged in the middle pretending to be one of the two devices. Every characteristic is tagged with how strong a bond you need before it will answer you, and that tag is the difference between the 4.0 and the 5.0.</p>

<h2 id="the-40-was-a-pushover">The 4.0 was a pushover</h2>

<p>WHOOP 4.0 exposes a custom service. You connect, you do one “just-works” write to its command characteristic, and iOS quietly brings up an encrypted link. After that the band streams: live heart rate, R-R intervals (the gaps between consecutive beats, which heart-rate variability is computed from), events, and a fourteen-day backlog of stored biometrics that the recovery and strain and sleep numbers are derived from. No passkey, no pairing dance. The whole “bonding trick” is a single write.</p>

<p>John’s client does all of that already: live heart rate on screen, history backfilling, the whole pipeline from band to local store. Then I pointed it at a 5.0 and every channel went dark.</p>

<h2 id="the-50-wall">The 5.0 wall</h2>

<p>The 5.0’s custom service looks familiar at first. Same shape as the 4.0: one characteristic you write commands to, several you subscribe to for pushed data, all under a renamed UUID.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fd4b0001-cce1-4033-93ce-002d5875f58a   (custom service)
  fd4b0002   write                → command channel
  fd4b0003   notify               → command responses
  fd4b0004   notify               → events
  fd4b0005   notify               → data / fragmented
  fd4b0007   notify               → new in 5.0
</code></pre></div></div>

<p>The familiarity ends the moment you touch it. Every attempt to subscribe or write comes straight back as a refusal from the Bluetooth stack itself, before the band’s own software ever sees the request:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>subscribe fd4b0003     → Code 15  Encryption is insufficient
subscribe fd4b0004/5/7 → Code 5   Authentication is insufficient
write     fd4b0002     → GATT     Insufficient Authentication
</code></pre></div></div>

<p>The exact wording matters. “Encryption is insufficient” would mean an ordinary encrypted link is enough. “Authentication is insufficient” is stricter: it wants the <em>authenticated</em> pairing from the primer, the kind that’s protected against a man-in-the-middle, not just-works. The 4.0 handed over biometrics on a just-works link. The 5.0 demands the strong handshake before it will so much as acknowledge a subscription.</p>

<p>I spent a while trying to make a Mac complete that handshake, because a Mac is where my tooling lived. Dead end, and worth saying plainly so nobody else burns the same day on it.<sup id="fnref:macos" role="doc-noteref"><a href="#fn:macos" class="footnote" rel="footnote">2</a></sup> Apple’s Bluetooth API on macOS gives you no way to initiate pairing directly. It will only pair as a side effect of <em>reading</em> an encrypted value, and the 5.0’s custom service has nothing readable to trigger that: the command characteristic is write-only, the rest are push-only, and the plain readable values aren’t encrypted. I even unpaired the band from the phone and tried six times from scratch to rule out the obvious “some other device is holding the only allowed bond” theory. Same refusal every time, and not once did the OS even show a pairing prompt. The band simply won’t do a standard Bluetooth pairing with an arbitrary computer. Its custom service authenticates through WHOOP’s own app and account, not through anything a generic client can drive.</p>

<h2 id="what-you-get-for-free">What you get for free</h2>

<p>Before going further it’s worth knowing what the 5.0 hands out with no bond at all. Bluetooth defines a handful of standard services that almost every device implements the same way, so any app can read them with no special knowledge and, here, no pairing:</p>

<ul>
  <li><strong>Heart Rate</strong> (service <code class="language-plaintext highlighter-rouge">0x180D</code>, characteristic <code class="language-plaintext highlighter-rouge">0x2A37</code>). A standardized format: a flags byte, then the heart rate, then R-R intervals when the flags say they’re present. I was decoding live heart rate off this within minutes, using the same standard-profile parser John’s client already had for the 4.0.</li>
  <li><strong>Battery</strong> (<code class="language-plaintext highlighter-rouge">0x180F</code> / <code class="language-plaintext highlighter-rouge">0x2A19</code>). A percentage.</li>
  <li><strong>Device Information</strong> (<code class="language-plaintext highlighter-rouge">0x180A</code>). Model <code class="language-plaintext highlighter-rouge">MG</code>, hardware <code class="language-plaintext highlighter-rouge">WS50_r03</code>, firmware <code class="language-plaintext highlighter-rouge">50.38.1.0</code>.</li>
</ul>

<p>That is a genuinely useful live feed and it costs nothing. But it’s shallow. No motion data, no raw optical heart-rate signal, and none of the stored biometric history the real metrics are built from. Everything with depth sits behind the authenticated bond.</p>

<h2 id="the-phone-already-holds-the-key">The phone already holds the key</h2>

<p>Here is the thing the Mac was missing. The official app, with my consent, during normal setup, already paired the band to my iPhone. iOS holds that bond at the system level. And an app running on that same phone inherits the encrypted transport.</p>

<p>So I stopped fighting the bond and just rode the one that already existed. An app on the bonded phone subscribed to the custom notify characteristics, and the band accepted every one:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FD4B0003 SUBSCRIBED — band accepted standard pairing
FD4B0004 SUBSCRIBED
FD4B0005 SUBSCRIBED
FD4B0007 SUBSCRIBED
</code></pre></div></div>

<p>No keys forged, no app auth impersonated. The OS bond established by the official app provided the encrypted link, and my app listened on it. Passive capture of traffic between two of my own bonded devices.</p>

<p>And then almost nothing happened.</p>

<h2 id="silence">Silence</h2>

<p>Subscribing opens the pipe. It does not make the band talk. Over the next several minutes exactly one frame arrived, on the events channel, and then nothing. The 5.0 behaves like the 4.0 in this respect: it is mute until something commands it to stream. The bond was never the whole problem. The other half is a command protocol I didn’t have, and the 5.0’s is not the 4.0’s.</p>

<p>I had two ways to learn that protocol. Guess it by poking the command characteristic and watching for reactions, which is slow and risks doing something destructive to a device I like. Or observe the official app speaking it once, cleanly, and read the conversation back.</p>

<h2 id="reading-bluetooth-without-a-sniffer">Reading Bluetooth without a sniffer</h2>

<p>The obvious move is a Bluetooth sniffer: some radio dongle yanking packets out of the air, followed by a lot of work to decrypt them. I didn’t do that. The whole trick is choosing where you tap.</p>

<p>Inside the phone, the Bluetooth software and the actual radio chip talk over an internal interface called HCI. Encryption gets applied below that line, on the chip, in the last instant before the bits hit the antenna. So whatever you log at HCI is caught before it was ever encrypted on the way out, or after it was already decrypted on the way in. Plaintext in both directions, no keys required.</p>

<p>iOS will just hand you that log. Apple publishes a configuration profile for debugging Bluetooth; you install it, do the thing you want recorded, and then pull a <code class="language-plaintext highlighter-rouge">sysdiagnose</code>, which is the giant diagnostic bundle the OS coughs up on demand. Somewhere inside it is a full capture of every Bluetooth message the phone sent and received. The first time you find it sitting there in the clear, it feels like cheating. It’s much closer to reading your own server’s request log than to wiretapping anybody.<sup id="fnref:hci" role="doc-noteref"><a href="#fn:hci" class="footnote" rel="footnote">3</a></sup></p>

<p>So I opened the WHOOP app, let it sync the band for a few minutes, and captured the whole exchange. One good capture: 8,031 frames on the custom service. That capture is the Rosetta stone for everything below.</p>

<h2 id="the-envelope">The envelope</h2>

<p>A parser written against a capture is only worth trusting if it can turn around and reproduce the capture. So the rule I held myself to from the start: decode every frame, re-encode it from the decoded fields, and require the re-encoded bytes to equal the originals. All 8,031 of them. If one frame failed to round-trip, the format was wrong. That constraint is what turned a pile of guesses into a confirmed spec.</p>

<p>The frame envelope that satisfies it, with multi-byte numbers stored low byte first (little-endian, the same convention x86 and ARM use in memory):</p>

<figure>
  <img src="/assets/images/writing/whoop5-frame.svg" alt="WHOOP 5.0 frame envelope: SOF, version, length, field, CRC-16/MODBUS header check, inner payload, CRC-32 trailer; inner expands to type, seq, cmd, b3, payload." />
  <figcaption>The 5.0 custom-channel envelope, and the inner command layout it wraps.</figcaption>
</figure>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[0xAA][0x01][len u16][field u16][crc16 of bytes 0..6][inner …][crc32 of inner]
</code></pre></div></div>

<p>A few of those fields took real work.</p>

<p><code class="language-plaintext highlighter-rouge">len</code> counts from byte 4 to four bytes before the end, so the total frame is <code class="language-plaintext highlighter-rouge">len + 8</code>. Easy once you stop assuming it counts the whole thing.</p>

<p>The checksums were the interesting part. A CRC is a checksum: a number computed from a run of bytes so the receiver can tell if any of them got corrupted in transit. This frame carries two of them, over two different regions, computed with two different algorithms, and you have to match each one exactly or your frames look broken. The trailing four bytes are a standard zlib CRC-32 (the same one in gzip and PNG) over the inner region, which the 4.0 also used, so I recognized it fast. The header check resisted. It’s two bytes covering the first six bytes of the frame, and none of the common CRC-16 variants matched until I ran the whole catalog of them and landed on the one labeled CRC-16/MODBUS. The parameters that define it, if you want to reproduce it, are polynomial <code class="language-plaintext highlighter-rouge">0x8005</code> reflected, initial value <code class="language-plaintext highlighter-rouge">0xFFFF</code>, input and output reflected, no final XOR. In Swift, which is the version that ships:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">/// CRC-16/MODBUS (poly 0x8005 reflected, init 0xFFFF, refin/refout, xorout 0).</span>
<span class="kd">static</span> <span class="kd">func</span> <span class="nf">crc16Modbus</span><span class="p">(</span><span class="n">_</span> <span class="nv">bytes</span><span class="p">:</span> <span class="kt">ArraySlice</span><span class="o">&lt;</span><span class="kt">UInt8</span><span class="o">&gt;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">UInt16</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">crc</span><span class="p">:</span> <span class="kt">UInt16</span> <span class="o">=</span> <span class="mh">0xFFFF</span>
    <span class="k">for</span> <span class="n">b</span> <span class="k">in</span> <span class="n">bytes</span> <span class="p">{</span>
        <span class="n">crc</span> <span class="o">^=</span> <span class="kt">UInt16</span><span class="p">(</span><span class="n">b</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">_</span> <span class="k">in</span> <span class="mi">0</span><span class="o">..&lt;</span><span class="mi">8</span> <span class="p">{</span>
            <span class="n">crc</span> <span class="o">=</span> <span class="p">(</span><span class="n">crc</span> <span class="o">&amp;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span> <span class="p">?</span> <span class="p">(</span><span class="n">crc</span> <span class="o">&gt;&gt;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">^</span> <span class="mh">0xA001</span> <span class="p">:</span> <span class="p">(</span><span class="n">crc</span> <span class="o">&gt;&gt;</span> <span class="mi">1</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">crc</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">field</code> word at bytes 4 and 5 is a small control value, not covered by either checksum, usually <code class="language-plaintext highlighter-rouge">00 01</code> on the wire for commands. I still don’t have a complete story for every value it takes, and I’m honest about that in the code.</p>

<h2 id="the-inner-layout-and-a-gift-from-the-40">The inner layout, and a gift from the 4.0</h2>

<p>Unwrap the envelope and the inner bytes are <code class="language-plaintext highlighter-rouge">[type][seq][cmd][b3][payload]</code>. The pleasant surprise: the 5.0 reuses the 4.0’s command numbers. <code class="language-plaintext highlighter-rouge">type</code> <code class="language-plaintext highlighter-rouge">0x23</code> means COMMAND. The command bytes I’d already seen on the 4.0 were right there: <code class="language-plaintext highlighter-rouge">0x91</code> GET_HELLO, <code class="language-plaintext highlighter-rouge">0x8d</code> GET_ADVERTISING_NAME, <code class="language-plaintext highlighter-rouge">0x22</code> GET_DATA_RANGE, <code class="language-plaintext highlighter-rouge">0x78</code> SET_CONFIG, <code class="language-plaintext highlighter-rouge">0x16</code> and <code class="language-plaintext highlighter-rouge">0x17</code> for the historical send-and-ack loop. Whoever built the 5.0 firmware kept the command vocabulary and rebuilt the frame around it.</p>

<p>The one byte that bites you is <code class="language-plaintext highlighter-rouge">b3</code>, the fourth inner byte. It is not constant. It varies per command, and the band is picky: send GET_HELLO with the wrong <code class="language-plaintext highlighter-rouge">b3</code> and you get nothing. GET_HELLO, GET_ADVERTISING_NAME, and SET_CONFIG want <code class="language-plaintext highlighter-rouge">0x01</code>. GET_DATA_RANGE and SEND_HISTORICAL want <code class="language-plaintext highlighter-rouge">0x00</code>. Get it wrong and the band just ignores you, which is the worst kind of bug because there’s no error to chase.</p>

<h2 id="making-it-stream">Making it stream</h2>

<p>The official app’s path from silence to a firehose of biometrics, reconstructed from the capture:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">GET_HELLO</code>, then <code class="language-plaintext highlighter-rouge">GET_ADVERTISING_NAME</code>.</li>
  <li>Fifteen <code class="language-plaintext highlighter-rouge">SET_CONFIG</code> writes, each one a feature flag. This is the step that actually unlocks the data. The payload is a feature name padded to 32 bytes, a one-byte value, then padding. The names read like exactly what they are: <code class="language-plaintext highlighter-rouge">enable_r22_packets</code>, <code class="language-plaintext highlighter-rouge">enable_r22_v2/v3/v5/v6/v8_packets</code>, <code class="language-plaintext highlighter-rouge">make_hrfm_visible</code>, <code class="language-plaintext highlighter-rouge">hr_ch_switching</code>, <code class="language-plaintext highlighter-rouge">enable_passive_strap_fit_gen5</code>, <code class="language-plaintext highlighter-rouge">enable_sig11_during_sleep</code>, and a handful more.</li>
  <li><code class="language-plaintext highlighter-rouge">GET_DATA_RANGE</code>, then a <code class="language-plaintext highlighter-rouge">SEND_HISTORICAL</code> and a continuing ack loop.</li>
</ol>

<p>Flip those flags and the band opens up the <strong>r22 stream</strong>: inner type <code class="language-plaintext highlighter-rouge">0x2f</code>, arriving on the data characteristic at a serious clip. About 6,300 frames a minute once it’s going. That is the high-rate biometric channel the whole exercise was after.</p>

<p>Getting from “it works in the capture” to “it works in my app” cost a few debugging cycles that are worth keeping, because every one of them was a silent failure with no error attached.</p>

<p><strong>The band buzzed every time I connected.</strong> I had a command in my enable sequence I’d labeled SET_CLOCK, opcode <code class="language-plaintext highlighter-rouge">0x42</code>, because that’s roughly what it does on the 4.0. On the 5.0, <code class="language-plaintext highlighter-rouge">0x42</code> is SET_ALARM_TIME, and writing it set an alarm that buzzed the band on my wrist on every single connect. Deleting one command made the buzzing stop. Naming things matters even when the things are bytes.</p>

<p><strong>Write-without-response is a no-op.</strong> I started with write-without-response for the command writes because it’s cheaper. The band silently ignores them. It only acts on write-with-response. There is no error. The writes just evaporate. Switching to write-with-response is what made the frames finally come fast.</p>

<p><strong>The stream stalls after one chunk unless you ack it.</strong> This was the good one. The r22 data starts, delivers one chunk, and stops. The trick is in the metadata frames the band interleaves with the data: every status frame of a particular subtype carries an 8-byte progress cursor buried at offset 13. You have to echo that cursor straight back as a <code class="language-plaintext highlighter-rouge">0x17</code> command, verbatim, and only then does the band send the next chunk. I confirmed it the satisfying way: in the capture, all 124 acks the app sent were byte-identical to the cursor in the status frame right before each one. Echo the cursor, the offload flows. Don’t, and it dies after one chunk.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// On each status frame carrying a cursor, echo it back as a 0x17 ack</span>
<span class="c1">// or the historical offload stalls after the first chunk.</span>
<span class="k">let</span> <span class="nv">cursor</span> <span class="o">=</span> <span class="kt">Array</span><span class="p">(</span><span class="n">frame</span><span class="o">.</span><span class="n">inner</span><span class="p">[</span><span class="mi">13</span><span class="o">..&lt;</span><span class="mi">21</span><span class="p">])</span>
<span class="nf">sendGen5</span><span class="p">(</span><span class="nv">cmd</span><span class="p">:</span> <span class="mh">0x17</span><span class="p">,</span> <span class="nv">b3</span><span class="p">:</span> <span class="mh">0x01</span><span class="p">,</span> <span class="nv">payload</span><span class="p">:</span> <span class="n">cursor</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>r22 only flows on-wrist.</strong> I lost an hour to this one. With everything correct, on-wrist gating means the band won’t emit r22 at all unless it’s actually being worn. Off-wrist you get heartbeat and console frames and nothing else. The fix was to put it on and stop overthinking the code.</p>

<h2 id="reading-the-sensor-bytes">Reading the sensor bytes</h2>

<p>The r22 frames are mostly a 112-byte variant. The trick to reading an unknown binary payload is to find the bytes that move the way a real quantity should, so you watch them over time and against the physical world. The fields that gave themselves up, counting bytes into the payload:</p>

<ul>
  <li>Bytes 7 through 10 are a timestamp, the band’s own clock, ticking once per second.</li>
  <li>Byte 14 is heart rate in beats per minute. Byte 29 is a <em>second</em> heart rate, which fits one of the feature flags being named <code class="language-plaintext highlighter-rouge">hr_ch_switching</code>. The two stay within a few bpm of each other, sit in a believable physiological range, and drift smoothly across forty minutes of capture, matching the standard heart-rate profile from earlier.</li>
  <li>Bytes 37, 41, and 45 are three ordinary 32-bit floating-point numbers: the accelerometer’s x, y, z, in multiples of gravity. The giveaway that it really is an accelerometer is that at rest the three combined have a length of about one (that’s gravity, one g, pulling straight down), and the direction swings around as you tilt the band. Three unrelated floats would not do that.</li>
</ul>

<p>I’ll admit to one wrong turn, because it’s a good lesson. I first read a block starting at byte 33 as an orientation quaternion, the four-number form 3D software uses to store which way something is facing. Four floats there had a combined length very close to 1, which is exactly what a valid orientation quaternion looks like, so it seemed like a lock. It was a coincidence. One of those four bytes was a separate value that happened to sit near zero, which dragged the combined length close to 1 for the wrong reason. The real signal was the accelerometer a few bytes further on. A length near 1 was suggestive, not proof. Watching the values respond correctly when I physically rotated the band is what counted as proof.</p>

<h2 id="where-it-stands">Where it stands</h2>

<p>The 5.0 now connects, bonds through the phone’s existing OS bond, replays the enable handshake, and streams the r22 biometric channel at thousands of frames a minute, with the ack loop keeping the offload alive. Live heart rate, R-R, and battery flow into the same store and the same views John’s client already had for the 4.0, so the 5.0 lights up the existing app instead of needing a new one. The codec round-trips all 8,031 captured frames byte for byte and is unit-tested against them.</p>

<p>I’m equally clear about what isn’t done. Two layers of sensor data are still raw. Fully calibrated motion (knowing which axis is which and in exactly what units) and the raw optical heart-rate waveform (the green-light signal a wrist sensor actually reads, known as PPG) both need <em>labeled</em> captures to finish: recordings where I do something known on cue, like hold still, then move, then cover and uncover the sensor, so the bytes can be lined up against ground truth. That’s the same labeled-capture method earlier work used on the 4.0’s sensors, and it’s the next layer here. And recovery, strain, and sleep stay scores WHOOP computes. I’m reading the inputs to those models, not re-deriving the models themselves. The honest summary: the connection and the protocol are solved and shipped, and the fine sensor calibration is in progress.</p>

<p>The whole point was never the 5.0 specifically. It was that the data my own body produces should be readable by software I run, on hardware I own, without asking anyone’s cloud for permission. Getting there meant defeating an authenticated bond and reversing a frame format just to read a pulse off my own wrist, which is satisfying and a little absurd in equal measure. Both bands now do it.</p>

<p>If you want to actually talk to a 5.0, the whole protocol is written up as an <a href="/experiments/whoop5/">interactive spec and playground</a>: the GATT map, the command set, the enable sequence and r22 field layout, plus live tools to decode and build frames yourself. It runs entirely in your browser.</p>

<blockquote>
  <p>This WHOOP work is independent and unofficial, not affiliated with, endorsed by, or sponsored by WHOOP, Inc. “WHOOP” is a trademark of its owner, used here only to name the hardware this software interoperates with. It is reverse-engineering for interoperability with a device I own, reading my own data, a protected interest under 17 U.S.C. §1201(f). No proprietary software, firmware, or assets are reproduced, and no access control, DRM, or paywall is circumvented. It is not a medical device, and heart rate, HRV, recovery, and the rest are approximations, not medical advice.</p>
</blockquote>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:priorart" role="doc-endnote">
      <p>And John’s client itself stands on earlier community reverse-engineering of the WHOOP protocol, without which none of this exists: <a href="https://github.com/jogolden/whoomp">whoomp</a>, <a href="https://github.com/christianmeurer/whoop-reader">whoop-reader</a>, <a href="https://github.com/bWanShiTong/openwhoop">openwhoop</a>, and <a href="https://github.com/bWanShiTong/reverse-engineering-whoop">reverse-engineering-whoop</a>. My 5.0 work is one more layer on that stack. <a href="#fnref:priorart" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:macos" role="doc-endnote">
      <p>Every legitimate macOS lever, in one place, for the curious: the Python BLE library’s <code class="language-plaintext highlighter-rouge">pair()</code> call isn’t implemented on macOS at all; no readable-encrypted value exists to trigger an auto-pair; subscribing or writing returns the auth error with no pairing prompt; the system showed no leftover bond to clear; and unpairing from the phone plus six fresh attempts changed nothing. macOS never completes the low-level pairing exchange (SMP, the Bluetooth pairing protocol) with this band. <a href="#fnref:macos" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:hci" role="doc-endnote">
      <p>The capture is of my own phone talking to my own band, with the official app, during a normal sync I initiated. No third party’s traffic, no circumvented access control. HCI logging is a documented Apple developer facility. The raw capture stays on my disk and is gitignored; what’s published is the protocol structure, not the bytes of my sessions. <a href="#fnref:hci" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Alec Jude Wilson</name><email>hi@judes.club</email></author><category term="reverse-engineering" /><category term="bluetooth" /><category term="ios" /><summary type="html"><![CDATA[The WHOOP 4.0 gave up its data with a single Bluetooth write. The 5.0 slammed the door: an authenticated, encrypted bond before it would say a word. Here's how I got the biometric stream out anyway, byte for byte, without a sniffer or a jailbreak.]]></summary></entry><entry><title type="html">The LLM never writes the query: a declarative search layer over sensitive records</title><link href="https://judes.club/writing/the-llm-never-writes-the-query/" rel="alternate" type="text/html" title="The LLM never writes the query: a declarative search layer over sensitive records" /><published>2026-05-21T00:00:00-04:00</published><updated>2026-05-21T00:00:00-04:00</updated><id>https://judes.club/writing/the-llm-never-writes-the-query</id><content type="html" xml:base="https://judes.club/writing/the-llm-never-writes-the-query/"><![CDATA[<p>We have an internal assistant. One of the things it does is find people.</p>

<p>By people I mean records in a system of record — names, contacts, home addresses, current assignments, and other personal details that exist in exactly one place and actually matter. This is the most sensitive data we hold.<sup id="fnref:sor" role="doc-noteref"><a href="#fn:sor" class="footnote" rel="footnote">1</a></sup> Staff can search it by typing a request in plain language, such as “find translators in France who speak Spanish,” and getting an answer back.</p>

<p>The model handles the request itself without much trouble. This post is about what happens between the request and the answer.</p>

<h2 id="the-setup">The setup</h2>

<p>First, some background on what the assistant is.</p>

<p>It’s an internal chat tool. A staff member opens it, types a request in plain language, and gets an answer. Under the hood it’s an LLM with a set of tools, which are small functions it’s allowed to call. The model has no direct connection to a database. When it needs data, it calls a tool, and the tool is the only code that touches a real record.</p>

<p>Finding people is one of those tools. When someone types “find translators in France who speak Spanish,” the model reads the request, works out the search criteria, and calls the person-search tool with them. The tool runs the query and returns the matches, and the model presents them.</p>

<p>So the model turns language into criteria, and the tool turns criteria into people. The rest of this post is about the tool: the interface it exposes and how it runs a query.</p>

<h2 id="the-model-doesnt-get-a-query-language">The model doesn’t get a query language</h2>

<p>The obvious way to build this is to give the model a flexible search tool and let it improvise — hand it something query-shaped and let it filter however it likes.</p>

<p>For data like this, that’s a bad idea. The records are read-only to the assistant by construction; it can’t write to them, and the whole feature is gated behind a permission claim.<sup id="fnref:claim" role="doc-noteref"><a href="#fn:claim" class="footnote" rel="footnote">2</a></sup> But read-only access still leaves room to read more than you should. If the model improvises its own queries, it can request data in shapes nobody reviewed beforehand.</p>

<p>So instead of a query language, the model gets a fixed vocabulary. Every criterion it can express is a small declared object with a field, an operator, and a value:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"field"</span><span class="p">:</span><span class="w"> </span><span class="s2">"departments"</span><span class="p">,</span><span class="w"> </span><span class="nl">"operator"</span><span class="p">:</span><span class="w"> </span><span class="s2">"current"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"departmentId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"…"</span><span class="p">,</span><span class="w"> </span><span class="nl">"isManager"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The model can’t invent a field or an operator. Whatever it sends is validated against a registry of things we marked as searchable, and that validation runs before any record is read. The model still works out which criteria a request needs, but it doesn’t control the shape of the search.</p>

<h2 id="not-everyone-sees-the-same-person">Not everyone sees the same person</h2>

<p>There’s a second reason the model doesn’t get a query language, and it matters more than the first.</p>

<p>Not everyone who uses the assistant sees the same data. Permissions here aren’t a single yes-or-no on whether you can search people; they’re more granular. Two people can run the same search and get back different fields on the same person, because each record is authorized field by field against the claims of whoever is asking. Which details you can see depends on who you are.</p>

<p>A free-form query language has no good way to handle that. It lets any caller name any field, then relies on the backend to drop whatever they weren’t cleared for on every query. The model ends up reasoning in a vocabulary that may not be valid for the person it’s working for.</p>

<p>A declared registry handles it in one place. The fields are finite and known, so “what can this caller search?” has a definite answer. You give each user only the fields their claims allow, and the model can’t form a criterion for a field it was never given. Fields a user isn’t cleared for are never offered to the model, so they don’t have to be filtered out of the results afterward.</p>

<h2 id="one-definition-five-jobs">One definition, five jobs</h2>

<p>The old version of this tool had two parallel systems bolted together. There were about sixteen typed parameters for the fast path, plus a stringly-typed JSON blob for everything else, with the filter logic reflected out of attributes at runtime. Adding one searchable field meant editing five files and making sure they stayed consistent. The two halves didn’t even agree on what “searchable” meant.</p>

<p>The rewrite replaces that with a single concept. A search field is defined once, and that one definition does five separate jobs.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">SearchField</span><span class="p">.</span><span class="n">ObjectCollection</span><span class="p">&lt;</span><span class="n">DepartmentAssignment</span><span class="p">&gt;(</span><span class="s">"departments"</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">Operators</span><span class="p">(</span><span class="n">Current</span><span class="p">,</span> <span class="n">Past</span><span class="p">,</span> <span class="n">Future</span><span class="p">,</span> <span class="n">Ever</span><span class="p">,</span> <span class="n">Never</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">TemporalRange</span><span class="p">(</span><span class="n">d</span> <span class="p">=&gt;</span> <span class="n">d</span><span class="p">.</span><span class="n">StartDate</span><span class="p">,</span> <span class="n">d</span> <span class="p">=&gt;</span> <span class="n">d</span><span class="p">.</span><span class="n">EndDate</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">Member</span><span class="p">(</span><span class="s">"departmentId"</span><span class="p">,</span> <span class="err">…</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">Member</span><span class="p">(</span><span class="s">"isManager"</span><span class="p">,</span> <span class="err">…</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">Phase1</span><span class="p">(</span><span class="s">"departmentIds"</span><span class="p">)</span>              <span class="c1">// how it narrows the server query</span>
    <span class="p">.</span><span class="nf">Selects</span><span class="p">(</span><span class="s">"departments { … }"</span><span class="p">);</span>        <span class="c1">// what it fetches back</span>
</code></pre></div></div>

<p>That one block produces all five things: the description the model reads, the rules its input is validated against, how the criterion gets pushed into the upstream search, what data we fetch back, and how a match is decided. Adding a field means writing one of these blocks, and all five jobs come from it.</p>

<p>The builder produces one object behind a small interface. The interface is those five jobs:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">interface</span> <span class="nc">ISearchField</span>
<span class="p">{</span>
    <span class="kt">string</span> <span class="n">Name</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>

    <span class="c1">// 1 — what the model is told this field is</span>
    <span class="k">void</span> <span class="nf">DescribeForLlm</span><span class="p">(</span><span class="n">StringBuilder</span> <span class="n">text</span><span class="p">);</span>

    <span class="c1">// 2 — is a given { operator, value } even legal here?</span>
    <span class="c1">//     null means fine; otherwise it's the error handed back to the model</span>
    <span class="kt">string</span><span class="p">?</span> <span class="nf">Validate</span><span class="p">(</span><span class="n">SearchOperator</span> <span class="n">op</span><span class="p">,</span> <span class="n">JsonElement</span> <span class="k">value</span><span class="p">);</span>

    <span class="c1">// 3 — push this criterion into the Phase-1 server query, if it can be</span>
    <span class="kt">bool</span> <span class="nf">TryContributeToPhase1</span><span class="p">(</span><span class="n">SearchOperator</span> <span class="n">op</span><span class="p">,</span> <span class="n">JsonElement</span> <span class="k">value</span><span class="p">,</span> <span class="n">Phase1Query</span> <span class="n">query</span><span class="p">);</span>

    <span class="c1">// 4 — what to pull back from the full record in Phase 2</span>
    <span class="kt">string</span><span class="p">?</span> <span class="n">GraphQlSelection</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>

    <span class="c1">// 5 — the final, authoritative yes/no for one person</span>
    <span class="kt">bool</span> <span class="nf">Evaluate</span><span class="p">(</span><span class="n">PersonRecord</span> <span class="n">person</span><span class="p">,</span> <span class="n">SearchOperator</span> <span class="n">op</span><span class="p">,</span> <span class="n">JsonElement</span> <span class="k">value</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two implementations cover every field we have. A scalar field is a single value on the record, such as a name, a date, or a status. An object-collection field is a list of sub-records, like assignments or languages, each with its own shape. The registry is a dictionary of these, keyed by name. Generating the prompt text, validating input, building the query, choosing what to fetch, and deciding the match are all the same loop over the same objects.</p>

<p>This is useful for sensitive data specifically. Because what’s searchable and what’s fetchable come from the same definition, you can’t accidentally fetch a field you didn’t mean to expose. A mistake in a field definition is contained to that one definition.</p>

<h2 id="two-phases-and-a-hard-limit">Two phases, and a hard limit</h2>

<p>The search runs in two phases, because the shape of the data doesn’t allow anything simpler.</p>

<p>There are around <strong>8–10 million</strong> person records, and each one has <strong>hundreds of fields</strong>. You can’t query that in a single shot. The search index, which is the thing that can scan all of those records quickly, only covers some of the fields — the common ones that are worth indexing. The rest of the fields live in the full record, which a fast query can’t see.</p>

<p>The two phases split along that line.</p>

<p>Phase 1 is a query against the upstream service. It narrows on the indexed fields — location, department, team — on the server, across all ten million records at once. Its job is to reduce ten million records to a small candidate set. That work has to happen on the upstream service; you don’t want to pull a large set of sensitive personal records into your own process just to sift through them.</p>

<p>Phase 2 takes the narrowed set, fetches the full records including the un-indexed fields, and evaluates the remaining criteria in memory. Those criteria are the ones the index can’t express, like “currently in this department,” which depends on a start date and an end date.</p>

<p>You can’t merge the two phases. Indexing hundreds of fields across ten million records and keeping it fast isn’t feasible, and neither is loading ten million full records into a process to filter them there. Each phase does the part it can.</p>

<p>There’s one rule between the phases that matters more than speed: a Phase-1 narrowing has to be sound. It’s allowed to return too many records, but it can’t drop a real match. If a criterion can’t be pushed down to Phase 1 without risking a false exclusion, we don’t push it, and Phase 2 evaluates it instead. We would rather return a slow result than a wrong one.</p>

<p>This is why <code class="language-plaintext highlighter-rouge">TryContributeToPhase1</code> returns a <code class="language-plaintext highlighter-rouge">bool</code>. The return value doesn’t mean “did this field contribute something.” It means “did it fully express this criterion on the server.” The tool tracks that across every criterion:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">needsPhase2</span> <span class="p">=</span> <span class="k">false</span><span class="p">;</span>
<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">criterion</span> <span class="k">in</span> <span class="n">criteria</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">bool</span> <span class="n">fullyPushed</span> <span class="p">=</span> <span class="n">criterion</span><span class="p">.</span><span class="n">Field</span><span class="p">.</span><span class="nf">TryContributeToPhase1</span><span class="p">(</span>
        <span class="n">criterion</span><span class="p">.</span><span class="n">Operator</span><span class="p">,</span> <span class="n">criterion</span><span class="p">.</span><span class="n">Value</span><span class="p">,</span> <span class="n">phase1Query</span><span class="p">);</span>
    <span class="n">needsPhase2</span> <span class="p">|=</span> <span class="p">!</span><span class="n">fullyPushed</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If every criterion pushes cleanly into the indexed query, <code class="language-plaintext highlighter-rouge">needsPhase2</code> stays <code class="language-plaintext highlighter-rouge">false</code> and Phase 2 is skipped. The Phase-1 result is the answer, and nothing gets enriched. If one criterion can’t be fully expressed on the server — a temporal check, an un-indexed field, a compound object match — Phase 2 runs, but only as a verification pass over a candidate set that’s already small. So the expensive path only runs when the criteria actually need it.</p>

<p>There’s also a ceiling. If Phase 1 returns more than <strong>2,000 candidates</strong>, the tool doesn’t fetch them; it asks for a narrower search instead.<sup id="fnref:ceiling" role="doc-noteref"><a href="#fn:ceiling" class="footnote" rel="footnote">3</a></sup> If a question is vague enough to match ten thousand people, the right response is to ask for more detail, not to load ten thousand full records into memory.</p>

<h2 id="currently">“Currently”</h2>

<p>Temporal scope is a good example of how an ordinary word ends up with a precise meaning here.</p>

<p>“Who’s in the translation department” and “who used to be in it” are different questions, and the difference is two dates on an assignment. So temporal scope is an operator. Every object field with a start and end date gets the same five operators — <code class="language-plaintext highlighter-rouge">current</code>, <code class="language-plaintext highlighter-rouge">past</code>, <code class="language-plaintext highlighter-rouge">future</code>, <code class="language-plaintext highlighter-rouge">ever</code>, <code class="language-plaintext highlighter-rouge">never</code> — and one function handles all of them:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">bool</span> <span class="nf">InScope</span><span class="p">(</span><span class="n">Assignment</span> <span class="n">a</span><span class="p">,</span> <span class="n">SearchOperator</span> <span class="n">op</span><span class="p">,</span> <span class="n">DateOnly</span> <span class="n">today</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="n">op</span> <span class="k">switch</span>
<span class="p">{</span>
    <span class="n">Current</span> <span class="p">=&gt;</span> <span class="p">(</span><span class="n">a</span><span class="p">.</span><span class="n">Start</span> <span class="k">is</span> <span class="k">null</span> <span class="p">||</span> <span class="n">a</span><span class="p">.</span><span class="n">Start</span> <span class="p">&lt;=</span> <span class="n">today</span><span class="p">)</span>
            <span class="p">&amp;&amp;</span> <span class="p">(</span><span class="n">a</span><span class="p">.</span><span class="n">End</span>   <span class="k">is</span> <span class="k">null</span> <span class="p">||</span> <span class="n">a</span><span class="p">.</span><span class="n">End</span>   <span class="p">&gt;=</span> <span class="n">today</span><span class="p">),</span>
    <span class="n">Past</span>    <span class="p">=&gt;</span> <span class="n">a</span><span class="p">.</span><span class="n">End</span>   <span class="k">is</span> <span class="p">{</span> <span class="p">}</span> <span class="n">end</span>   <span class="p">&amp;&amp;</span> <span class="n">end</span>   <span class="p">&lt;</span>  <span class="n">today</span><span class="p">,</span>
    <span class="n">Future</span>  <span class="p">=&gt;</span> <span class="n">a</span><span class="p">.</span><span class="n">Start</span> <span class="k">is</span> <span class="p">{</span> <span class="p">}</span> <span class="n">start</span> <span class="p">&amp;&amp;</span> <span class="n">start</span> <span class="p">&gt;</span>  <span class="n">today</span><span class="p">,</span>
    <span class="n">Ever</span>    <span class="p">=&gt;</span> <span class="k">true</span><span class="p">,</span>
    <span class="n">_</span>       <span class="p">=&gt;</span> <span class="k">false</span><span class="p">,</span>   <span class="c1">// Never: every item is in scope, negated after the match</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The model doesn’t work with dates at all. It picks the word <code class="language-plaintext highlighter-rouge">current</code>, and the function defines what <code class="language-plaintext highlighter-rouge">current</code> means.</p>

<p>The other half of a match is the value. For an object field, the value isn’t a single scalar; it’s a partial object. Matching works by subset containment: the criterion holds if every member it names matches the item, and members it doesn’t name are ignored.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">bool</span> <span class="nf">Contains</span><span class="p">(</span><span class="n">Assignment</span> <span class="n">item</span><span class="p">,</span> <span class="n">JsonElement</span> <span class="k">value</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">member</span> <span class="k">in</span> <span class="k">value</span><span class="p">.</span><span class="nf">EnumerateObject</span><span class="p">())</span>
        <span class="k">if</span> <span class="p">(!</span><span class="n">field</span><span class="p">.</span><span class="n">Members</span><span class="p">[</span><span class="n">member</span><span class="p">.</span><span class="n">Name</span><span class="p">].</span><span class="nf">Matches</span><span class="p">(</span><span class="n">item</span><span class="p">,</span> <span class="n">member</span><span class="p">.</span><span class="n">Value</span><span class="p">))</span>
            <span class="k">return</span> <span class="k">false</span><span class="p">;</span>   <span class="c1">// a named member disagreed</span>
    <span class="k">return</span> <span class="k">true</span><span class="p">;</span>            <span class="c1">// everything named agreed</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is what lets one field cover a range of questions. <code class="language-plaintext highlighter-rouge">{ departmentId: X }</code> means “in department X, in any role.” Adding <code class="language-plaintext highlighter-rouge">isManager: true</code> narrows it to “managing department X.” Choosing the operator <code class="language-plaintext highlighter-rouge">current</code> narrows it further, to “managing department X right now.” It’s the same field and the same two functions in each case; the model just names more members or picks a different operator.</p>

<p>Evaluating the field end to end works as you’d expect: take the person’s assignments, keep the ones that are <code class="language-plaintext highlighter-rouge">InScope</code> for the operator, and check whether any of them <code class="language-plaintext highlighter-rouge">Contains</code> the value. <code class="language-plaintext highlighter-rouge">Never</code> runs the same check and negates the result.</p>

<h2 id="read-only-by-construction">Read-only by construction</h2>

<p>The safety properties here come from the structure of the tool, not from runtime checks added at the end.</p>

<p>The tool has no write path, so there’s no code path that could change a record. The feature is gated behind a claim, so it isn’t available to people who shouldn’t have it. The model can only name fields from a fixed registry, only use the operators those fields declared, and only receive the data those definitions specify. The candidate pool is capped. Every criterion is validated before any record is read.</p>

<p>Taken together, this means the model only ever receives fields it named explicitly, and it can only name fields from a vocabulary we defined.</p>

<h2 id="why-build-it-this-way">Why build it this way</h2>

<p>Giving the model a plain search box would have been faster to build, and for most features that tradeoff is fine.</p>

<p>But the data being searched is a record of real people, and I didn’t want “the AI improvised a query” to be a possible explanation for an incident involving that data. The declarative layer isn’t there to be elegant, although it is convenient that adding a field now takes one block instead of edits across five files. It’s there so that the only way to use the tool is also a way we reviewed in advance.</p>

<p>The model is good at turning language into criteria, and that’s the job it has here. What data a query is allowed to touch is decided by the tool and the field definitions, not improvised by the model. Keeping those two responsibilities separate is most of what the design does.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:sor" role="doc-endnote">
      <p>A system of record is the authoritative source for a piece of data — not a cache or a copy. There’s exactly one, and the assistant reads it live rather than a snapshot. <a href="#fnref:sor" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:claim" role="doc-endnote">
      <p>A claim is a capability flag carried on the caller’s token. Without it the person-search tool isn’t even listed to the model, so it can’t be called at all. <a href="#fnref:claim" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:ceiling" role="doc-endnote">
      <p>The 2,000 figure is configurable. It’s set where Phase-2 enrichment latency starts to dominate the response — past that, a narrower query is faster than a bigger fetch. <a href="#fnref:ceiling" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Alec Jude Wilson</name><email>hi@judes.club</email></author><category term="ai" /><category term="architecture" /><category term="engineering" /><summary type="html"><![CDATA[We have an internal assistant that searches a system of record for people — the most sensitive data we hold. Here's the query layer behind it: one definition per field, two phases, and a model that never gets to write a query itself.]]></summary></entry><entry><title type="html">Skipping net/http: 249,000 requests per second in Go</title><link href="https://judes.club/writing/unsafe-at-any-speed/" rel="alternate" type="text/html" title="Skipping net/http: 249,000 requests per second in Go" /><published>2025-08-04T00:00:00-04:00</published><updated>2025-08-04T00:00:00-04:00</updated><id>https://judes.club/writing/unsafe-at-any-speed</id><content type="html" xml:base="https://judes.club/writing/unsafe-at-any-speed/"><![CDATA[<p><strong>The result:</strong> 249,000 req/s on localhost, 17,000+ over a real network, zero panics, zero leaks.</p>

<h2 id="1-the-baseline">1. The baseline</h2>

<ul>
  <li>Stock <code class="language-plaintext highlighter-rouge">net/http</code>, tuned: around <strong>9,000 req/s</strong>.</li>
  <li>Keep-alives off: under <strong>3,000</strong>.</li>
  <li>The cost is the per-request correctness work. Good for production, in the way for a load generator.</li>
</ul>

<h2 id="2-the-connection">2. The connection</h2>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">UnsafeConnection</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">conn</span>      <span class="n">net</span><span class="o">.</span><span class="n">Conn</span>
    <span class="n">buffer</span>    <span class="p">[]</span><span class="kt">byte</span>
    <span class="n">bufferPtr</span> <span class="n">unsafe</span><span class="o">.</span><span class="n">Pointer</span>
    <span class="n">isReady</span>   <span class="kt">int64</span>
<span class="p">}</span>
</code></pre></div></div>

<ul>
  <li><strong>512 workers</strong> share <strong>1,024 persistent TCP connections</strong>.</li>
  <li>Nothing reconnects mid-run, so setup time never lands in the numbers.</li>
  <li>Buffers are preallocated and reused, so the GC has almost nothing to do.</li>
</ul>

<h2 id="3-zero-copy-reads">3. Zero-copy reads</h2>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">safeUnsafeByteSlice</span><span class="p">(</span><span class="n">ptr</span> <span class="n">unsafe</span><span class="o">.</span><span class="n">Pointer</span><span class="p">,</span> <span class="n">length</span> <span class="kt">int</span><span class="p">)</span> <span class="p">[]</span><span class="kt">byte</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">ptr</span> <span class="o">==</span> <span class="no">nil</span> <span class="o">||</span> <span class="n">length</span> <span class="o">&lt;=</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="o">*</span><span class="p">(</span><span class="o">*</span><span class="p">[]</span><span class="kt">byte</span><span class="p">)(</span><span class="n">unsafe</span><span class="o">.</span><span class="n">Pointer</span><span class="p">(</span><span class="o">&amp;</span><span class="n">reflect</span><span class="o">.</span><span class="n">SliceHeader</span><span class="p">{</span>
        <span class="n">Data</span><span class="o">:</span> <span class="kt">uintptr</span><span class="p">(</span><span class="n">ptr</span><span class="p">),</span>
        <span class="n">Len</span><span class="o">:</span>  <span class="n">length</span><span class="p">,</span>
        <span class="n">Cap</span><span class="o">:</span>  <span class="n">length</span><span class="p">,</span>
    <span class="p">}))</span>
<span class="p">}</span>
</code></pre></div></div>

<ul>
  <li>Builds a slice straight over raw memory: no copy, no allocation.</li>
  <li><code class="language-plaintext highlighter-rouge">reflect.SliceHeader</code> plus <code class="language-plaintext highlighter-rouge">unsafe.Pointer</code> bypasses Go’s memory model. Fast, and actually dangerous.</li>
</ul>

<h2 id="4-containing-the-blast-radius">4. Containing the blast radius</h2>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">worker</span> <span class="o">*</span><span class="n">UnsafeWorker</span><span class="p">)</span> <span class="n">safeWorkerLoop</span><span class="p">(</span><span class="n">client</span> <span class="o">*</span><span class="n">UnsafeBenchmarkClient</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">defer</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">r</span> <span class="o">:=</span> <span class="nb">recover</span><span class="p">();</span> <span class="n">r</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="c">// Log and keep going</span>
        <span class="p">}</span>
    <span class="p">}()</span>
    <span class="c">// Write request, read response, track success/errors</span>
<span class="p">}</span>
</code></pre></div></div>

<ul>
  <li>Every worker’s hot loop runs under <code class="language-plaintext highlighter-rouge">recover()</code>.</li>
  <li>One bad worker logs and dies. The other 511 keep going.</li>
  <li>Unsafe in the small, stable in the large.</li>
</ul>

<h2 id="5-measuring-honestly">5. Measuring honestly</h2>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">RunCrashProofUnsafeBenchmark</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">NUM_BENCHMARKS</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span> <span class="p">{</span>
        <span class="c">// Launch all workers with sync.WaitGroup</span>
        <span class="c">// Sleep for the benchmark duration</span>
        <span class="c">// Collect all request counts</span>
        <span class="c">// Calculate RPS, mean, median, stddev, etc.</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<ul>
  <li>15 full cycles, reporting mean, median, and standard deviation.</li>
  <li>The number that matters is the <strong>coefficient of variation</strong> (stddev ÷ mean): under 5% is excellent, under 10% good, under 20% moderate, above that poor.</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Run 1:   248,210 req/s
Run 2:   249,379 req/s
Run 3:   247,844 req/s
...
Mean:                 248,477 req/s
Median:               248,210 req/s
Std deviation:            637 req/s
Coefficient of var.:     0.26%
</code></pre></div></div>

<ul>
  <li>0.26% CV means the speed is repeatable, not a lucky spike.</li>
</ul>

<h2 id="notes">Notes</h2>

<ul>
  <li><strong>Don’t ship this.</strong> <code class="language-plaintext highlighter-rouge">unsafe</code>, a hand-rolled pool, and slices over raw pointers are a recipe for memory corruption in production.</li>
  <li><strong>It’s fine for load generation.</strong> You control the tool, and the safety you give up was only ever protecting you.</li>
  <li><strong>Takeaway:</strong> Go has far more headroom than <code class="language-plaintext highlighter-rouge">net/http</code> gives you. Keep the danger in one small, deliberate place and put a <code class="language-plaintext highlighter-rouge">recover()</code> around it.</li>
</ul>]]></content><author><name>Alec Jude Wilson</name><email>hi@judes.club</email></author><category term="go" /><category term="performance" /><category term="engineering" /><summary type="html"><![CDATA[A Go load generator that drops net/http for raw TCP and unsafe pointers, hitting 249,000 requests per second on localhost with zero panics. The code, with cliff notes.]]></summary></entry><entry><title type="html">The differences between Disney’s and Universal’s trash cans</title><link href="https://judes.club/writing/disney-universal-trash-cans/" rel="alternate" type="text/html" title="The differences between Disney’s and Universal’s trash cans" /><published>2021-08-12T00:00:00-04:00</published><updated>2021-08-12T00:00:00-04:00</updated><id>https://judes.club/writing/disney-universal-trash-cans</id><content type="html" xml:base="https://judes.club/writing/disney-universal-trash-cans/"><![CDATA[<p><img src="/assets/images/writing/disney-trashcan-cover.jpeg" alt="A Star Wars-themed trash can in Disney's Galaxy's Edge" /></p>

<p>I recently spent ten days in Orlando visiting Disney World and Universal Orlando. I liked all the parks — they were fun and genuinely enjoyable.</p>

<p>We did every park except one (not counting water parks):</p>

<ul>
  <li><strong>Disney:</strong> Hollywood Studios, Epcot, Animal Kingdom, Magic Kingdom</li>
  <li><strong>Universal:</strong> Islands of Adventure, Universal Studios Florida <em>(didn’t make it)</em></li>
</ul>

<p>We actually had to end the trip two days early because of a death in the family back home, which is why Universal Studios Florida is the one we missed.</p>

<h2 id="differences-in-the-parks">Differences in the parks</h2>

<p>On the surface they seem like the same thing — big amusement parks in Florida. But I noticed a lot of differences almost immediately.</p>

<p>One of them was the trash cans.</p>

<p><strong>Disney</strong> felt more polished across the board. The staff all seemed trained the same way, the trash cans all matched, and the employee uniforms matched wherever they were stationed. The same went for the general organization of each park and for accessibility — wheelchairs and that kind of thing.</p>

<p><strong>Universal</strong> had most of what Disney had, but it just wasn’t as “polished.” There were many different types of trash cans, and some ride themes made no sense at all. The staff weren’t trained as consistently either — and don’t get me wrong, they were good employees, they just didn’t extend their service the way Disney’s did. Uniforms were another tell: at Disney, the employees by the Star Wars rides dressed like Star Wars characters. At Universal, the uniforms only matched the theme about half the time.</p>

<h2 id="trash-cans">Trash cans</h2>

<p>Okay — onto what this article is actually about.</p>

<h3 id="disney">Disney</h3>

<p>Disney had exactly one type of trash can across the entire park — the one in the photo above. Different paint and theming for different areas, sure, but underneath it was the same can every single time. Occasionally it was paired with a recycling can — still the exact same style, just with a round hole for cans and bottles instead of a swinging flap.</p>

<p>Personally, I loved that design. It’s simple and clean, and everybody instantly knows it’s a trash can.</p>

<h3 id="universal">Universal</h3>

<p>Universal had at least four different trash can types: big concrete ones, round metal ones, square metal ones, and some that looked a lot like Disney’s. Most of them were paired with recycling cans too. And those were just the variations I could find afterward — I’m pretty sure there were ten more out in the park.</p>

<h2 id="why-does-it-even-matter">Why does it even matter?</h2>

<p>I found myself struggling to answer that one.</p>

<p>The biggest problem with Universal’s approach is probably guests not being sure whether something is a trash can or a potted plant. But does that really matter? Not really — there are trash cans everywhere, in every park, Disney or Universal.</p>

<p>So in the end, I don’t think it actually matters. But Disney clearly has the edge on feeling <em>finished</em> — and not just with the trash cans. With everything.</p>]]></content><author><name>Alec Jude Wilson</name><email>hi@judes.club</email></author><category term="travel" /><category term="design" /><summary type="html"><![CDATA[Ten days in Orlando, two theme park empires, and one small detail that quietly gave away which company sweats the small stuff.]]></summary></entry><entry><title type="html">Hacking GETTR in 34 minutes</title><link href="https://judes.club/writing/hacking-gettr/" rel="alternate" type="text/html" title="Hacking GETTR in 34 minutes" /><published>2021-07-09T00:00:00-04:00</published><updated>2021-07-09T00:00:00-04:00</updated><id>https://judes.club/writing/hacking-gettr</id><content type="html" xml:base="https://judes.club/writing/hacking-gettr/"><![CDATA[<p><img src="/assets/images/writing/gettr-cover.jpeg" alt="The GETTR logo" /></p>

<p>GETTR launched as the big new conservative social network — Trump world’s answer to Twitter. So, naturally, the first thing I wanted to know was how it was built.</p>

<p>Long story short: it took me 34 minutes to reverse-engineer GETTR’s internal API, and then to abuse it badly enough to get rate-limited.</p>

<p>It wasn’t hard. What actually surprised me was how little stood in the way. There was no real API rate limit at all. To prove that to myself, I had a script create around <strong>1,000 posts in a minute and a half</strong> — no throttling, no checks, nothing anywhere telling me to slow down.</p>

<p>And here’s the part I still think is funny: the thing that <em>finally</em> stopped me wasn’t GETTR’s API. It was Cloudflare. The rate limit that eventually kicked in was Cloudflare’s generic protection sitting in front of the site — not anything GETTR had built themselves.</p>

<p>By the end I had an account with roughly a thousand posts on it. Scrolling from the top of them to the bottom took 22 seconds:</p>

<figure>
  <video src="/assets/images/writing/gettr-spam.mp4" autoplay="" loop="" muted="" playsinline="" preload="metadata"></video>
  <figcaption>~1,000 posts, all created in about 90 seconds.</figcaption>
</figure>

<p>For a platform that launched to that much attention, that’s a lot of front door left unlocked.</p>]]></content><author><name>Alec Jude Wilson</name><email>hi@judes.club</email></author><category term="security" /><summary type="html"><![CDATA[GETTR launched as Trump world's answer to Twitter. It took me 34 minutes to reverse-engineer its internal API — and the thing that finally stopped me wasn't GETTR at all.]]></summary></entry></feed>