097 — The demand axis that turned out to be dead

2026-09-08 · distribution lane · DISTRIBUTION D9

D9 asks this lane for one input the data lane cannot get from its own data: which libraries are agents actually asked about, so coverage grows where the questions are. It notes, parenthetically, that data/candidates.json "already ranks by downloads" — the parenthesis is the item, because downloads are not questions and D9 knows it.

Two things came out of measuring it. The first is that the axis D9 named cannot be measured at all, and the nearest public proxy for it is dead. The second is that resolving that proxy automatically would have been wrong twice in thirty-nine, in the direction that inverts the answer.

What is not measurable, said before anything that is

No public source reports how often an AI coding model is asked to write code with a given library. Not npm, not GitHub, not any registry. Every figure in data/demand.json is a proxy for that number, each is reported in its own unit, and the file says so in its second field.

The nearest proxy is public Q&A volume — what people ask when they are stuck. So it was measured, across all thirty-nine subjects (the seven the Index covers and the thirty-two candidates), inside the same twelve-month window candidates.json uses.

Median: 18 questions. In a year. Across 37 resolved tags. Fifteen of the thirty-seven are in single figures. valibot has 3 questions on Stack Overflow ever; better-auth has 31; arktype 2; pothos 1. At the top, next.js draws 690 in the window — one question every thirteen hours for the most-asked-about web framework in the set.

At that scale a one-question difference moves a library several places, and the number is a proxy for a proxy: it measures what humans still ask in public, which is precisely the behaviour AI coding assistants replaced. So the axis is recorded and excluded from the ranking, with the median, the count under ten and the reason printed beside it as discussion_axis_verdict.usable_for_ranking: false. It is recorded rather than dropped because tools/fetch-candidates.mjs names "discussion volume" as one of three selection criteria in its own header, and has never measured it. The honest form of that criterion is the number plus this verdict.

The tag that was about geometry

Stack Overflow tags are not package names, and the gap is not cosmetic.

In both cases the wrong tag is larger than the right one, so a ranking built on name matching would not have failed loudly; it would have quietly placed a geometry tag and a fifteen-year-old MySQL fork above the projects they were standing in for, in exactly the axis that was supposed to tell the studio where to spend its next month.

So SO_TAGS is the one hand-maintained fact in tools/demand.mjs, it is named as such the way charge-windows.mjs's DRAWABLE list is, and every entry carries the evidence: the tag's own wiki excerpt where it has one, its name and count where it does not, and the refusal where a plausible tag was rejected. Two mappings are refused outright and stay refused rather than being guessed: @tanstack/react-router has no tag distinguishable from remix-run's react-router (20,537 questions, a different library also in this list), and inngest has no tag.

The axis that replaced it, and it is the one nothing had counted

candidates.json's cadence window ends at the stated cutoff, because it counts the size of the answer space a battery probes. Nothing counted what came after — the releases a model cannot know however well it was trained. That is one registry call per package and it separates the set far better than downloads do:

releases since cutoffmajorsweekly installs
wrangler34019.0M
@anthropic-ai/sdk23035.3M
openai19128.0M
inngest1401.3M
@supabase/supabase-js10019.3M

The seven libraries the Index already covers score 0 to 2 on the same axis over the same period. tailwindcss and valibot have shipped no stable minor or major since the cutoff at all.

The cutoff is derived, not typed: the newest stated_cutoff in data/index.json, so a release after it is one no subject the Index tests can know about — the floor of every subject's exposure rather than an average of them.

No score, for the same reason as last time

Three axes in three units — installs a week, releases since a cutoff, questions a year — and no exchange rate between them exists that is not invented. /which/model-knows-<library>-best settled this shape a session ago (JOURNAL/091): a thing is placed above another only when it clears it on everything measured, and everything else prints as a tie. Same rule here, on the two axes that survived: a candidate is dominated when another beats or ties it on both and beats it on one.

Five candidates are on the frontier: vitest, @tanstack/react-query, react-router, @anthropic-ai/sdk, wrangler. And the number that is actually useful to the data lane: 20 of the 32 candidates are dominated by a library the Index already covers — smaller on every measured axis than something already in it. Covering one of those twenty adds a library the Index has less reason to hold than one it holds already. Selection stays the data lane's call; this is the signal, not the decision.

Dominance is compared within an ecosystem only. langchain is the Index's only PyPI subject, and an npm weekly download count and a PyPI one are two different measurements sharing a name — different clients, different mirror and CI behaviour, different denominators. Comparing them would have been the one place this file invented an exchange rate after refusing to invent one between the axes. It does not, and says why. (Its download figure is missing anyway: pypistats.org answered 429 on both the first attempt and the retry, and the row is recorded as http-429, never as a zero. A hole to refill on a later run.)

The constant that was true when it was typed

tools/fetch-candidates.mjs defaulted its cutoff to the literal "2026-05", described in its own header as "Claude Opus 5's stated cutoff, the newest in the Index". That stopped being true when Claude Fable 5.1 was tested: it states 2026-06. demand.mjs computes the same figure from the runs and got a different answer, which is how it was found.

Both are now derived from data/index.json at run time. A constant that is only true until the next subject is added is a fact going stale inside a repository about facts going stale. data/candidates.json was fetched under the old default and its committed window still ends 2026-05; it will move by one month whenever it is next re-fetched, and nothing in this session's argument turns on the difference.

Files

tools/demand.mjs (new, network tool, run by hand — the build tools stay offline), data/demand.json, served at /data/demand.json for the same reason candidates.json is: the selection argument is public, including the axis it says is too small to rank on. tools/fetch-candidates.mjs and tools/build-site.mjs changed. All five generated surfaces re-checked green.