Skip to content

Commit c2a45a6

Browse files
committed
feat(kpi): commit the two measurements the ranker work was judged by
Both existed only as throwaway scripts while five ranker fixes were decided on their output, which meant the evidence for every one of those commits was one `rm -rf /tmp` from being unreproducible. kpi:recall — recall@6 for BOTH rankers on one corpus: this site's, and the @imqueue MCP server's rankEntries, whose corpus it rebuilds to match loadIndex() + loadApiIndex() exactly. An agent reads all six results search_docs returns, so membership is the whole question and rank inside the set is noise; measure.js's position decay answers the human question instead. --list enumerates the queries the MCP ranker answers and this one does not, which is the number that actually gates the switch. It degrades to the site half alone when the sibling checkout is absent rather than failing. kpi:compare — the working tree's ranker against another commit's, query by query, both loaded in one process over one corpus so the only variable is the code, and the baseline read from git so it cannot drift. This exists because an aggregate hid a real regression twice: a -0.2 macro that was three named queries, one leaving the top ten; and a 0.0 natural that was simultaneously dropping 13 artificial queries, two of them 1 -> 11 and 10 -> 24. That change was reverted on the strength of this list, and nothing in measure.js would have objected. harness.js gains recall@6, and finally loads the PEER tiers — x1/x2 — which it ignored from the start. That omission is the plan's biggest risk, not a detail: imqueue.com reaches the ranker only through those feeds, so `pricing commercial license` was scored against imqueue.org's own /license/ alone. The one question with revenue attached was the half measured least. Optional, since a local build:org produces no peer feed and evaluate() already scores an external hit as a miss. Reproduces the numbers the ranker commits claimed: site recall@6 99.5% against the MCP ranker's 83.9%, and zero queries where the MCP ranker still wins. The site KPI is unchanged at 94.0/88.9 natural and 91.2/95.4 artificial, so loading the peers moves no aggregate. One correction those commits deserve: compare.js reports 392 artificial queries better against a38250a, not the 376 recorded, because the scratchpad script did not load the peer feeds. The committed figures were conservative.
1 parent 5867797 commit c2a45a6

4 files changed

Lines changed: 388 additions & 15 deletions

File tree

‎package.json‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,18 @@
3333
"probe:agent-analytics": "node scripts/probe-agent-analytics.js",
3434
"check:dates": "node scripts/gen-page-dates.js --check",
3535
"check:sitemap": "node scripts/check-sitemap.js",
36-
"check:llms": "node scripts/check-llms.js",
37-
"check:search-index": "node scripts/check-search-index.js _site-org && node scripts/check-search-index.js _site-com",
38-
"check:search-ranking": "node scripts/check-search-ranking.js",
39-
"check:search-ui": "node scripts/check-search-ui.js",
40-
"kpi:search": "node scripts/search-kpi/measure.js",
41-
"kpi:search:worst": "node scripts/search-kpi/measure.js --worst 40",
42-
"kpi:search:harvest": "node scripts/search-kpi/harvest-natural.js && node scripts/search-kpi/judge-natural.js",
43-
"kpi:search:gen": "node scripts/search-kpi/gen-artificial.js --count 10000",
44-
"gen-search-index": "node scripts/gen-search-index.js",
45-
"check:jsonld": "node scripts/check-jsonld.js",
36+
"check:llms": "node scripts/check-llms.js",
37+
"check:search-index": "node scripts/check-search-index.js _site-org && node scripts/check-search-index.js _site-com",
38+
"check:search-ranking": "node scripts/check-search-ranking.js",
39+
"check:search-ui": "node scripts/check-search-ui.js",
40+
"kpi:search": "node scripts/search-kpi/measure.js",
41+
"kpi:search:worst": "node scripts/search-kpi/measure.js --worst 40",
42+
"kpi:search:harvest": "node scripts/search-kpi/harvest-natural.js && node scripts/search-kpi/judge-natural.js",
43+
"kpi:search:gen": "node scripts/search-kpi/gen-artificial.js --count 10000",
44+
"gen-search-index": "node scripts/gen-search-index.js",
45+
"check:jsonld": "node scripts/check-jsonld.js",
4646
"check:mermaid": "node scripts/check-mermaid.js",
47-
"check:geo-live": "node scripts/check-geo-live.js",
47+
"check:geo-live": "node scripts/check-geo-live.js",
4848
"check:api-versions": "node scripts/check-api-versions.js",
4949
"check:links": "npm run build:all && node scripts/check-links.js",
5050
"check:links:external": "npm run build:all && node scripts/check-links.js --external --allow-external=scripts/external-allowlist.txt",
@@ -70,7 +70,9 @@
7070
"indexnow:org": "node scripts/indexnow-ping.js org --exclude=/api/",
7171
"indexnow:com": "node scripts/indexnow-ping.js com",
7272
"gen-syndication": "node scripts/gen-syndication.js",
73-
"gen-social": "node scripts/gen-social-preview.js"
73+
"gen-social": "node scripts/gen-social-preview.js",
74+
"kpi:recall": "node scripts/search-kpi/recall.js",
75+
"kpi:compare": "node scripts/search-kpi/compare.js"
7476
},
7577
"repository": {
7678
"type": "git",

‎scripts/search-kpi/compare.js‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// compare.js — the working tree's ranker against another commit's, query by query.
2+
//
3+
// node scripts/search-kpi/compare.js [--ref HEAD] [--dir _site-org] [--worst 40]
4+
//
5+
// WHY, and it is the whole reason this file exists: an aggregate can hold still while the results
6+
// churn underneath it, and it has hidden a real regression twice.
7+
//
8+
// * A change that read -0.2 on natural macro turned out to be three named queries moving, one of
9+
// them out of the top ten. The macro alone looked like rounding.
10+
// * A change that read 0.0 on natural — genuinely unmoved — was simultaneously dropping 13
11+
// artificial queries, two of them from #1 to #11 and #10 to #24. It was reverted on the
12+
// strength of this list, and nothing in measure.js's output would have objected.
13+
//
14+
// So: never judge a ranker change by the summary. Read who won and who lost.
15+
//
16+
// Both rankers are loaded in ONE process over ONE prepared corpus, so the only variable is the
17+
// code. The baseline comes out of git rather than a hand-kept copy, which means it cannot drift
18+
// from what it claims to be.
19+
'use strict';
20+
21+
const fs = require('node:fs');
22+
const os = require('node:os');
23+
const path = require('node:path');
24+
const { execFileSync } = require('node:child_process');
25+
26+
const { load, page, accuracyFor } = require('./lib/harness');
27+
28+
const ROOT = path.join(__dirname, '..', '..');
29+
const RANKER = path.join('src', '_shared', 'js', 'search.js');
30+
31+
const arg = (name, fallback) => {
32+
const i = process.argv.indexOf(name);
33+
34+
return i === -1 ? fallback : process.argv[i + 1];
35+
};
36+
37+
const REF = arg('--ref', 'HEAD');
38+
const DIR = arg('--dir', path.join(ROOT, '_site-org'));
39+
const WORST = Number(arg('--worst', 40));
40+
41+
function baselineRanker() {
42+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kpi-baseline-'));
43+
const file = path.join(dir, 'search.js');
44+
45+
// maxBuffer: the ranker is ~100 kB and execFileSync's default is 1 MB, so this is comfortable —
46+
// but it is the kind of limit that fails only after the file has grown, so it is stated.
47+
const source = execFileSync('git', ['show', `${REF}:${RANKER}`], {
48+
cwd: ROOT,
49+
encoding: 'utf8',
50+
maxBuffer: 8 * 1024 * 1024,
51+
});
52+
53+
fs.writeFileSync(file, source);
54+
55+
return file;
56+
}
57+
58+
function rankOf(ranker, query, expect) {
59+
let hits;
60+
61+
try {
62+
hits = ranker.search(ranker.parseQuery(query));
63+
} catch {
64+
return 0;
65+
}
66+
67+
for (let i = 0; i < hits.length; i++) {
68+
if (!hits[i].external && expect.includes(page(hits[i].record.u))) {
69+
return i + 1;
70+
}
71+
}
72+
73+
return 0; // absent
74+
}
75+
76+
function main() {
77+
const baselineFile = baselineRanker();
78+
// Order matters only in that both must see the same corpus; load() prepares it per ranker, so
79+
// neither can be measured against a corpus the other did not have.
80+
const before = load(DIR, baselineFile);
81+
const after = load(DIR);
82+
83+
const readJson = (file) => JSON.parse(fs.readFileSync(path.join(__dirname, 'data', file), 'utf8'));
84+
const sets = {
85+
natural: readJson('natural-judged.json').judged,
86+
artificial: readJson('artificial-queries.json').main,
87+
};
88+
89+
let dirty = false;
90+
91+
for (const [name, cases] of Object.entries(sets)) {
92+
const better = [];
93+
const worse = [];
94+
95+
for (const testCase of cases) {
96+
const expect = (Array.isArray(testCase.expect) ? testCase.expect : [testCase.expect])
97+
.map(page);
98+
const b = rankOf(before, testCase.query, expect);
99+
const a = rankOf(after, testCase.query, expect);
100+
101+
if (accuracyFor(a) > accuracyFor(b)) better.push({ ...testCase, b, a });
102+
if (accuracyFor(a) < accuracyFor(b)) worse.push({ ...testCase, b, a });
103+
}
104+
105+
console.log(`\n=== ${name} (n = ${cases.length}) vs ${REF} ===`);
106+
console.log(`better: ${better.length} worse: ${worse.length} `
107+
+ `unchanged: ${cases.length - better.length - worse.length}`);
108+
109+
if (!worse.length) {
110+
continue;
111+
}
112+
113+
dirty = true;
114+
115+
console.log(`\n WORSE (before -> after, 0 = absent from the result set):`);
116+
117+
worse
118+
.sort((x, y) => (accuracyFor(y.b) - accuracyFor(y.a)) - (accuracyFor(x.b) - accuracyFor(x.a)))
119+
.slice(0, WORST)
120+
.forEach((w) => console.log(` ${String(w.b).padStart(3)} -> ${String(w.a).padStart(3)} `
121+
+ `${w.query.slice(0, 52).padEnd(52)} [${w.label || w.bucket}]`));
122+
123+
if (worse.length > WORST) {
124+
console.log(` … and ${worse.length - WORST} more (pass --worst N)`);
125+
}
126+
}
127+
128+
// Not an exit failure: a change that trades some queries for more of others can still be right,
129+
// and only a person can decide that. This is a flag, not a verdict.
130+
if (dirty) {
131+
console.log('\nSome queries got worse. Read them before keeping the change.');
132+
} else {
133+
console.log('\nNo query got worse.');
134+
}
135+
}
136+
137+
main();

‎scripts/search-kpi/lib/harness.js‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,21 @@ const path = require('node:path');
2323

2424
const ROOT = path.join(__dirname, '..', '..', '..');
2525

26-
function load(dir) {
26+
// `rankerFile` measures a DIFFERENT copy of the ranker — a snapshot from another commit — which
27+
// is what compare.js uses to diff two versions in one process. Left undefined it measures the
28+
// working tree.
29+
function load(dir, rankerFile) {
2730
const indexDir = dir || path.join(ROOT, '_site-org');
28-
const ranker = require(path.join(ROOT, 'src', '_shared', 'js', 'search.js'));
31+
const ranker = require(rankerFile || path.join(ROOT, 'src', '_shared', 'js', 'search.js'));
2932

30-
const read = (name) => {
33+
const read = (name, optional) => {
3134
const file = path.join(indexDir, name);
3235

3336
if (!fs.existsSync(file)) {
37+
if (optional) {
38+
return null;
39+
}
40+
3441
throw new Error(`${file} is missing — run \`npm run build:org\` first`);
3542
}
3643

@@ -40,6 +47,21 @@ function load(dir) {
4047
ranker.state.t1 = ranker.prepare(read('search-index.json'));
4148
ranker.state.t2 = ranker.prepareSections(read('search-text.json'));
4249

50+
// The PEER tiers, which this harness ignored for its first three months — and that omission is
51+
// the plan's own biggest risk, not a detail. imqueue.com reaches the ranker only through these,
52+
// so without them `pricing commercial license` scores against imqueue.org's `/license/` alone:
53+
// a plausible answer, from the wrong edition, with nothing anywhere reporting a problem. The
54+
// one question with revenue attached was the half being measured least.
55+
//
56+
// Optional, because a local `npm run build:org` produces no peer feed and that is a supported
57+
// state — evaluate() already scores an `external` hit as a miss, so a measurement without them
58+
// is pessimistic rather than wrong.
59+
const peerIndex = read('search-peer-index.json', true);
60+
const peerText = read('search-peer-text.json', true);
61+
62+
ranker.state.x1 = peerIndex ? ranker.prepare(peerIndex) : null;
63+
ranker.state.x2 = peerText ? ranker.prepareSections(peerText) : null;
64+
4365
return ranker;
4466
}
4567

@@ -122,6 +144,11 @@ function summarise(results) {
122144
top1: (count((r) => r.position === 1) / total) * 100,
123145
top3: (count((r) => r.position >= 1 && r.position <= 3) / total) * 100,
124146
top5: (count((r) => r.position >= 1 && r.position <= 5) / total) * 100,
147+
// recall@6 — the AGENT metric, and deliberately not position-decayed. search_docs returns six
148+
// results and an agent reads all six, so whether the page is in the set is the whole question
149+
// and its rank inside the set is noise. For a human the opposite holds, which is why accuracy
150+
// above exists too. Same runs, two readers.
151+
top6: (count((r) => r.position >= 1 && r.position <= 6) / total) * 100,
125152
top10: (count((r) => r.position >= 1 && r.position <= 10) / total) * 100,
126153
absent: (count((r) => r.position === 0) / total) * 100,
127154
mrr: mrr * 100,
@@ -148,6 +175,7 @@ function table(label, summary) {
148175
` #1 exactly ${pct(summary.top1)}`,
149176
` in top 3 ${pct(summary.top3)}`,
150177
` in top 5 ${pct(summary.top5)}`,
178+
` recall@6 ${pct(summary.top6)}`,
151179
` in top 10 ${pct(summary.top10)}`,
152180
` never found ${pct(summary.absent)}`,
153181
` MRR ${pct(summary.mrr)}`,

0 commit comments

Comments
 (0)