Skip to content

Commit 4b0cb84

Browse files
committed
refactor(search): the ranker moves to its own repo, pinned as a submodule
search.js is now github.com/imqueue/search-ranker, included here at vendor/search-ranker and to be included identically by @imqueue/mcp. The reason is drift: the MCP server carried its own implementation of the same ranking rules, the two answered identical queries differently, and nothing compared them — recall@6 was 99.5% here against 83.9% there before anyone measured. One file with one history cannot drift from itself. Byte-for-byte, so this changes no answer: kpi:compare reports 0 better, 0 worse, 12,279 unchanged across the natural and artificial sets, and the served asset is the submodule's own hash (verified in the browser — dialog opens, index loads, "retry a failed call" returns the right answer at #1). THE FAILURE THIS GUARDS AGAINST. buildAssetManifest() finds JS by scanning directories, and an unpopulated submodule is an EMPTY DIRECTORY: the scan would find no *.js, report nothing, and emit a complete-looking site with no search.js in it — a missing asset, not a build error, and no check would object. So the ranker is handled by NAME rather than by adding its directory to the scan, and its absence throws at eleventy config load with `git submodule update --init` in the message. Both failure modes were tested by causing them. It also refuses to be shadowed: a src/**/js/search.js reappearing is a hard error rather than "later source wins", because that file would be served in preference to the pinned ranker with nothing saying so — the exact drift this split ends. Ordered last in the function so the collision is detectable at all. scripts/lib/ranker.js exists because the path was spelled five times independently (three checks, the KPI harness, the asset scan). Moving the file meant finding all five, and a missed one fails differently in each caller. compare.js needed more than a new path: `--ref` names a commit in the RANKER's history, which is no longer in this repo's history at all, so its git calls now run with cwd inside the submodule. Uninitialised, git says only "does not exist in 'HEAD'", which names neither the cause nor the fix — so that case is caught and explained. submodules: true on all six checkout steps, in five workflows — every one of them runs `npm test` or `npm run build:all`, so without it each fails at the throw above instead of checking anything. Cloudflare Pages needs no such flag: it clones and populates public HTTPS submodules, proven on a throwaway branch before this was started, which is also why .gitmodules must stay public HTTPS rather than SSH. Contributor note in the README, because a plain `git clone` yields a tree that looks complete and a build that stops for a reason nothing on disk explains.
1 parent e0c4540 commit 4b0cb84

18 files changed

Lines changed: 206 additions & 2791 deletions

.github/workflows/checks.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ jobs:
4141
# file would appear to have been added by it and the check would fail on
4242
# every run.
4343
fetch-depth: 0
44+
# REQUIRED: the search ranker is the imqueue/search-ranker submodule at
45+
# vendor/search-ranker. Without this the checkout leaves it empty and the
46+
# build throws (scripts/lib/asset-manifest.js) — which is the designed
47+
# behaviour, but it means every run here fails rather than checking
48+
# anything. Every workflow in this repo that runs `npm test` or
49+
# `npm run build:all` needs it, and all of them do.
50+
submodules: true
4451

4552
- uses: actions/setup-node@v7
4653
with:

.github/workflows/indexnow.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ jobs:
2121
runs-on: ubuntu-latest
2222
steps:
2323
- uses: actions/checkout@v7
24+
with:
25+
# REQUIRED by `npm run build:all` below: the search ranker is a submodule
26+
# at vendor/search-ranker, and the build throws without it. See checks.yml.
27+
submodules: true
2428

2529
- uses: actions/setup-node@v7
2630
with:

.github/workflows/refresh-api-docs.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ jobs:
5656
# date. The default shallow fetch has one commit, so every file looks as
5757
# though that commit added it and the check fails on every run.
5858
fetch-depth: 0
59+
# REQUIRED for the same step: the search ranker is a submodule at
60+
# vendor/search-ranker, and the build throws without it. See checks.yml.
61+
submodules: true
5962

6063
- uses: actions/setup-node@v7
6164
with:

.github/workflows/sync-cli-guide.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ jobs:
3838
# as though that commit added it and the check fails on every run.
3939
# Same reason refresh-api-docs.yml sets it.
4040
fetch-depth: 0
41+
# REQUIRED by the same step: the search ranker is a submodule at
42+
# vendor/search-ranker, and the build throws without it. See checks.yml.
43+
submodules: true
4144

4245
- name: Resolve cli ref
4346
id: ref

.github/workflows/weekly-maintenance.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ jobs:
5151
# file looks as though one commit added it. Same reason refresh-api-docs.yml
5252
# and sync-cli-guide.yml set it.
5353
fetch-depth: 0
54+
# REQUIRED by the `npm test` step below: the search ranker is a submodule
55+
# at vendor/search-ranker, and the build throws without it. See checks.yml.
56+
submodules: true
5457

5558
- uses: actions/setup-node@v7
5659
with:
@@ -122,6 +125,10 @@ jobs:
122125
issues: write
123126
steps:
124127
- uses: actions/checkout@v7
128+
with:
129+
# REQUIRED: check:links:external builds both editions, and the build throws
130+
# without the vendor/search-ranker submodule. See checks.yml.
131+
submodules: true
125132
- uses: actions/setup-node@v7
126133
with:
127134
node-version: 22

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "search-ranker"]
2+
path = vendor/search-ranker
3+
url = https://github.com/imqueue/search-ranker.git

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,30 @@ pages live in `src/org/` and `src/com/`, and Eleventy ignores the other one.
1313

1414
## Local development
1515

16+
**Clone with submodules.** The search ranker is its own repo
17+
([imqueue/search-ranker](https://github.com/imqueue/search-ranker)), pinned here at
18+
`vendor/search-ranker` and pinned identically by the `@imqueue/mcp` server, so the site
19+
and the MCP tool cannot answer the same query differently. A plain `git clone` leaves that
20+
directory empty and the build stops with the fix in the message — deliberately, because the
21+
alternative is a site that builds cleanly and ships no `search.js` at all.
22+
23+
```bash
24+
git clone --recurse-submodules https://github.com/imqueue/imqueue.com.git
25+
# already cloned?
26+
git submodule update --init
27+
```
28+
1629
```bash
1730
npm install
1831
npm run serve:org # imqueue.org — http://localhost:8080
1932
npm run serve:com # imqueue.com — http://localhost:8081
2033
```
2134

35+
To change the ranker, edit `vendor/search-ranker/search.js`, measure with
36+
`npm run kpi:compare` (never by the summary alone — read the per-query deltas), then commit
37+
**inside the submodule** and commit the moved pointer here. `scripts/search-kpi/README.md`
38+
has the numbers and the rejected experiments.
39+
2240
## Build
2341

2442
```bash

scripts/check-search-index.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,13 @@ for (const name of ['search-peer-index.json', 'search-peer-text.json']) {
272272
}
273273

274274
// ---- the ranker and the feeds agree about the shape ----------------------------
275-
// Two independent declarations, compared here on purpose. The ranker is about to live in its own
276-
// repository and be pinned as a submodule, while the feeds it reads are fetched LIVE — so a
277-
// pinned ranker reads today's feeds. Move a tuple position without moving the version and every
278-
// score is computed off the wrong field, with nothing to report: no exception, no empty result,
279-
// just quietly wrong answers. Failing the build is the only place this is cheap to catch.
275+
// Two independent declarations, compared here on purpose. The ranker lives in its own repository
276+
// and is pinned here as a submodule, while the feeds it reads are fetched LIVE — so a pinned
277+
// ranker reads today's feeds. Move a tuple position without moving the version and every score
278+
// is computed off the wrong field, with nothing to report: no exception, no empty result, just
279+
// quietly wrong answers. Failing the build is the only place this is cheap to catch.
280280
{
281-
const ranker = require(path.join(__dirname, '..', 'src', '_shared', 'js', 'search.js'));
281+
const ranker = require(path.join(__dirname, 'lib', 'ranker.js')).requireRanker();
282282
const { FEED_V } = require(path.join(__dirname, 'lib', 'search-corpus.js'));
283283

284284
if (ranker.FEED_V !== FEED_V) {

scripts/check-search-ranking.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
// node scripts/check-search-ranking.js
55
//
66
// Relevance is the part of a search feature that regresses silently. Every weight in
7-
// src/org/js/search.js was set to fix a specific wrong answer, and each of those is
8-
// one "small improvement" away from coming back — the @imqueue MCP server's ranker
9-
// learned this the hard way, twice. So each case below names the signal it protects
10-
// and the wrong answer that was actually observed before it existed.
7+
// the ranker was set to fix a specific wrong answer, and each of those is one "small
8+
// improvement" away from coming back — the @imqueue MCP server's ranker learned this
9+
// the hard way, twice. So each case below names the signal it protects and the wrong
10+
// answer that was actually observed before it existed.
1111
//
1212
// The ranker is required directly: it exports itself when there is no `document`,
13-
// which is why it can be checked here instead of through a browser screenshot.
13+
// which is why it can be checked here instead of through a browser screenshot. It
14+
// lives in a submodule now, so this file is also the check that notices an
15+
// unpopulated one — requireRanker() throws with the fix.
1416
//
1517
// Exits non-zero on any failure; wired into `npm test`.
1618

@@ -21,7 +23,7 @@ const path = require('node:path');
2123

2224
const ROOT = path.join(__dirname, '..');
2325
const OUT = path.join(ROOT, '_site-org');
24-
const ranker = require(path.join(ROOT, 'src', '_shared', 'js', 'search.js'));
26+
const ranker = require('./lib/ranker.js').requireRanker();
2527

2628
let failures = 0;
2729
const fail = (msg) => { failures++; console.error(` FAIL ${msg}`); };

scripts/check-search-ui.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,17 @@ for (const [re, ok, why] of JS_CASES) {
172172
// ---- what search reports ----------------------------------------------------
173173
// The measurement is the only route to improving relevance with real readers instead of
174174
// hand-written ground truth (scripts/search-kpi/), so it is worth a few cases of its own.
175-
const searchJs = read(path.join(ROOT, 'src', '_shared', 'js', 'search.js'));
175+
// Read as TEXT, not required: these cases assert on source strings, which is the only way to
176+
// check a constant that never leaves the IIFE. From the submodule — see scripts/lib/ranker.js,
177+
// and check its presence first so a plain clone gets the instruction rather than an ENOENT.
178+
const rankerLib = require('./lib/ranker.js');
179+
180+
if (!rankerLib.exists()) {
181+
console.error(rankerLib.MISSING);
182+
process.exit(1);
183+
}
184+
185+
const searchJs = read(rankerLib.RANKER_FILE);
176186

177187
// A settle window is the difference between "queries people asked" and a report full of
178188
// their own prefixes. Asserted as a NUMBER, not a mention: `var SETTLE = 0` would satisfy

0 commit comments

Comments
 (0)