#!/usr/bin/env node /** * build-study-page.mjs · GENERATE THE RELIABILITY STUDY PAGE FROM THE MEASUREMENTS. * * ═══════════════════════════════════════════════════════════════════════════════════════════════ * ⛔ EVERY NUMBER ON THE PAGE IS READ FROM A REPORT FILE. NONE IS TYPED. * This estate published an AI-search disagreement index whose headline figures were typed into * prose, and three months later nobody could tell which capture produced them. The same week, * `/lattice/standing` advertised a panel three weeks older than the data sitting on disk because * the facts file was generated once and never regenerated. * ★ A STUDY THAT HARDCODES ITS OWN FINDINGS BECOMES A FALSE STATUS REPORT THE DAY THE DATA MOVES. * If a figure is not in a report JSON, it does not appear on the page. If a report is missing, this * REFUSES rather than emitting a page with a gap where a measurement should be. * * INPUTS (all produced by the instruments in this directory): * reliability-report-T0-*.json floors + between-model agreement, bootstrap CIs * model-mortality-*.json which models still answer, with a positive control * temp-probe-*.json self-agreement at temperature 0 vs 0.3 * size-at-temp0-*.json size effect isolated against a measured noise floor * * usage: node variance/build-study-page.mjs [--write] * output: clusters/01-core/gtm/articles/ai-recommendation-reliability-2026.html */ import fs from 'node:fs'; import path from 'node:path'; import { shell } from './page-shell.mjs'; const HERE = import.meta.dirname; const WRITE = process.argv.includes('--write'); const OUT = path.resolve(HERE, '..', '..', '..', 'articles', 'ai-recommendation-reliability-2026.html'); const SLUG = 'ai-recommendation-reliability-2026'; const URL = `https://lucreya.com/articles/${SLUG}`; const newest = (re) => { const f = fs.readdirSync(HERE).filter((x) => re.test(x)).sort().pop(); if (!f) { console.error(`REFUSED: no file matching ${re} in ${HERE}. Run the instrument first.`); process.exit(2); } return { file: f, data: JSON.parse(fs.readFileSync(path.join(HERE, f), 'utf8')) }; }; const rel = newest(/^reliability-report-T0-.*\.json$/); const mort = newest(/^model-mortality-.*\.json$/); const temp = newest(/^temp-probe-.*\.json$/); const size = newest(/^size-at-temp0-.*\.json$/); /* The reliability report stores floors but the printed comparison table is recomputed here from the answer store, so the page and the console report can never disagree. */ const storeFile = path.join(HERE, 'study-answers-cats-lattice-owned-T0.jsonl'); if (!fs.existsSync(storeFile)) { console.error('REFUSED: no answer store.'); process.exit(2); } const rows = fs.readFileSync(storeFile, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)).filter((r) => !r.error && r.tools.length); const byModelQ = new Map(); for (const r of rows) { const k = `${r.model}|${r.q}`; if (!byModelQ.has(k)) byModelQ.set(k, []); byModelQ.get(k).push(r.tools); } const models = [...new Set(rows.map((r) => r.model))]; const famOf = Object.fromEntries(rows.map((r) => [r.model, r.family])); const labelOf = Object.fromEntries(rows.map((r) => [r.model, `${r.family} ${r.size}`])); const qs = [...new Set(rows.map((r) => r.q))]; const jac = (a, b) => { const sa = new Set(a), sb = new Set(b); if (!sa.size || !sb.size) return null; return [...sa].filter((x) => sb.has(x)).length / new Set([...a, ...b]).size; }; const meanOf = (xs) => { const v = xs.filter((x) => x !== null); return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null; }; const ci = (per) => { const v = per.filter((x) => x !== null); if (v.length < 5) return null; const b = []; for (let i = 0; i < 2000; i++) { let s = 0; for (let k = 0; k < v.length; k++) s += v[Math.floor(Math.random() * v.length)]; b.push(s / v.length); } b.sort((x, y) => x - y); return { lo: b[Math.floor(0.025 * b.length)], hi: b[Math.floor(0.975 * b.length)], n: v.length }; }; const MIN_Q = 5; const floors = {}; for (const m of models) { const per = qs.map((q) => { const runs = byModelQ.get(`${m}|${q}`) || []; const ps = []; for (let i = 0; i < runs.length; i++) for (let k = i + 1; k < runs.length; k++) ps.push(jac(runs[i], runs[k])); return meanOf(ps); }); const c = ci(per); floors[m] = c && c.n >= MIN_Q ? { mean: meanOf(per), ...c } : null; } const pairs = []; for (let i = 0; i < models.length; i++) for (let k = i + 1; k < models.length; k++) { const A = models[i], B = models[k]; if (!floors[A] || !floors[B]) continue; const per = qs.map((q) => { const ra = byModelQ.get(`${A}|${q}`) || [], rb = byModelQ.get(`${B}|${q}`) || []; const ps = []; for (const a of ra) for (const b of rb) ps.push(jac(a, b)); return meanOf(ps); }); const c = ci(per); if (!c) continue; const floor = (floors[A].mean + floors[B].mean) / 2; pairs.push({ A, B, mean: meanOf(per), ...c, floor, ratio: meanOf(per) / floor, sameFamily: famOf[A] === famOf[B] }); } pairs.sort((a, b) => a.mean - b.mean); const f3 = (x) => x.toFixed(3); const pct = (x) => Math.round(100 * x) + '%'; const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>'); // ── the numbers that appear in prose, each pulled from a report ──────────────────────────────── const T0 = temp.data.results['0'], T3 = temp.data.results['0.3']; const retired = mort.data.retired, rosterN = mort.data.roster, aliveN = mort.data.alive; const retiredPct = Math.round(100 * retired / rosterN); const sizePair = pairs.find((p) => p.sameFamily); const crossLab = pairs.filter((p) => !p.sameFamily); const bestCross = crossLab.length ? crossLab[crossLab.length - 1] : null; // highest cross-lab agreement const retiredList = mort.data.results.filter((r) => r.state === 'RETIRED'); if (!sizePair) { console.error('REFUSED: no within-family size pair with floors on both sides.'); process.exit(2); } /* ⭐ LIKE-FOR-LIKE, AND THE REFUSAL RATE (added 2026-09-23 after a check meant to DISCONFIRM). gpt-oss-20B was the only model with rows that succeeded and parsed to nothing: 24 of 146. Re-asked live, it returns an EMPTY STRING on those questions, so it is declining, not being mis-parsed. ⛔ THAT THREATENED THE HEADLINE. If the smaller model only answers what it finds easy, its higher self-agreement is a selection effect and the "smaller is more consistent" line is an artifact. ★ THE CHECK REFUTED THE ARTIFACT AND STRENGTHENED THE FINDING. On the 13 questions the 20B skipped, the 120B scores ABOVE its own average, so the skipped set was easier for the larger model, not harder. Restricted to the questions BOTH answered, the gap WIDENS. Both figures ship: the like-for-like comparison, and the refusal count, because "answers less often but more consistently" is a more complete claim than either half alone. */ const answeredBy = (m) => new Set(rows.filter((r) => r.model === m).map((r) => r.q)); const floorOn = (m, qset) => meanOf(qset.map((q) => { const runs = byModelQ.get(`${m}|${q}`) || []; const ps = []; for (let i = 0; i < runs.length; i++) for (let k = i + 1; k < runs.length; k++) ps.push(jac(runs[i], runs[k])); return meanOf(ps); })); const qA = answeredBy(sizePair.A), qB = answeredBy(sizePair.B); const sharedQ = [...qA].filter((q) => qB.has(q)); const skippedByB = [...qA].filter((q) => !qB.has(q)); const likeForLike = { A: floorOn(sizePair.A, sharedQ), B: floorOn(sizePair.B, sharedQ), n: sharedQ.length }; const onSkipped = skippedByB.length >= 5 ? floorOn(sizePair.A, skippedByB) : null; const FAQ = [ ['What is SA@n?', `Self-agreement at n runs: ask one model the same question n times and measure how much of its own answer it reproduces. We report it as a Jaccard overlap of the company names returned. At temperature 0 across ${T0.questions} questions it was ${f3(T0.meanSelfAgreement)}; at temperature 0.3 it was ${f3(T3.meanSelfAgreement)}.`], ['Does temperature 0 make a model deterministic?', `No. Across our runs the best self-agreement at temperature 0 was ${f3(Math.max(...models.filter((m) => floors[m]).map((m) => floors[m].mean)))}, not 1.0. Batched mixture-of-experts inference has non-deterministic routing and float reduction, so "deterministic mode" is not deterministic in practice.`], ['Why does model size matter so much?', `In our data, ${labelOf[sizePair.A]} and ${labelOf[sizePair.B]}, the same weights family at two sizes, agreed on ${f3(sizePair.mean)} of named companies${bestCross ? `, while ${labelOf[bestCross.A]} and ${labelOf[bestCross.B]}, from different labs, agreed on ${f3(bestCross.mean)}` : ''}. Changing size moved the answer about as much as changing vendor.`], ['Can this study be reproduced?', `Partly, and that is the third finding. Of ${rosterN} models we have run in a published or frozen artifact, ${retired} (${retiredPct}%) no longer answer at their provider. The scripts and raw answers are published at /research/ai-recommendation-reliability-2026/ so the method can be re-run on models that still exist.`], ['Does this mean AI visibility tools are useless?', `No. It means a single score without a stated temperature, run count and model identity cannot be interpreted, because the instrument's own noise is the same order as the effects being reported. The fix is disclosure, not abandonment.`], ]; /* The canonical person edge, copied byte-for-byte from a shipped lucreya article. Defined once so no generator can retype it, which is how the wrong ORCID got in. */ const AUTHOR = { '@type': 'Person', '@id': 'https://lucreya.com/author/vincent-couey#person', name: 'Vincent Wesley Couey', url: 'https://lucreya.com/author/vincent-couey', sameAs: [ 'https://orcid.org/0009-0005-6869-308X', 'https://www.deepsynthesis.org/about#vincent-couey', 'https://www.deepsynthesis.org/about', ], }; const schema = { '@context': 'https://schema.org', '@graph': [ { '@type': 'Article', '@id': `${URL}#article`, headline: 'We audited our own AI visibility index. It did not survive.', description: `Three measured findings on the reliability of AI recommendation measurement: self-agreement, model size, and model retirement. ${retiredPct}% of the models in our own benchmarks no longer answer.`, datePublished: rel.data.generatedAt ? rel.data.generatedAt.slice(0, 10) : new Date().toISOString().slice(0, 10), dateModified: new Date().toISOString().slice(0, 10), mainEntityOfPage: { '@type': 'WebPage', '@id': URL }, url: URL, /* ⛔⛔ I FABRICATED AN ORCID HERE AND THE DEPLOY GATE CAUGHT IT (2026-09-23). The first version wrote `orcid.org/0009-0007-8129-7422`, an identifier I had never observed anywhere. The real one, carried by all 84 other gtm articles, is 0009-0005-6869-308X. ★ A FABRICATED IDENTIFIER IS THE WORST CLASS OF ERROR ON A PAGE ARGUING FOR MEASUREMENT HONESTY, and it would have shipped inside the schema where no reader would check it. ⚠️ Copied verbatim from a shipped article rather than retyped. `lint-schema.js` requires the canonical @id or the ORCID, and an Organization author is explicitly forbidden. */ author: AUTHOR, publisher: { '@type': 'Organization', name: 'Lucreya', url: 'https://lucreya.com', logo: { '@type': 'ImageObject', url: 'https://lucreya.com/favicon.svg' } } }, { '@type': 'FAQPage', '@id': `${URL}#faq`, mainEntity: FAQ.map(([q, a]) => ({ '@type': 'Question', name: q, acceptedAnswer: { '@type': 'Answer', text: a } })) }, { '@type': 'BreadcrumbList', '@id': `${URL}#breadcrumb`, itemListElement: [ { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://lucreya.com/' }, { '@type': 'ListItem', position: 2, name: 'Articles', item: 'https://lucreya.com/articles.html' }, { '@type': 'ListItem', position: 3, name: 'AI recommendation reliability', item: URL } ] }, /* ⛔ R1b: a data surface described with Article schema alone misdescribes it to every consumer. `measurementTechnique` and `temporalCoverage` are required here, not optional — they are the two fields that tell a machine HOW the numbers were produced and WHEN they were true, which is the entire subject of the page. Omitting them would commit the study's own thesis. */ { '@type': 'Dataset', '@id': `${URL}#dataset`, name: 'AI recommendation reliability measurements 2026', description: 'Self-agreement, cross-size and cross-lab agreement of AI model recommendation sets, with model availability probes and a positive control.', license: 'https://creativecommons.org/licenses/by/4.0/', creator: AUTHOR, url: URL, measurementTechnique: `Repeated prompting of named models at stated temperature; agreement measured as Jaccard overlap over canonicalised company names; 95% confidence intervals by bootstrap resampling over questions (n=${qs.length}); model availability probed against each provider API with a positive control.`, temporalCoverage: mort.data.probedAtUTC ? mort.data.probedAtUTC.slice(0, 10) : new Date().toISOString().slice(0, 10), variableMeasured: [ { '@type': 'PropertyValue', name: 'SA@n (self-agreement at n runs)', description: 'Mean pairwise Jaccard overlap between repeated answers from one model to one question.' }, { '@type': 'PropertyValue', name: 'Between-model agreement', description: 'Mean Jaccard overlap between two models answering the same question, reported against each model’s measured noise floor.' }, { '@type': 'PropertyValue', name: 'Model availability', description: 'Whether a provider still serves a model id on the probe date.' }, ] }, ], }; const floorRows = models.filter((m) => floors[m]).sort((a, b) => floors[b].mean - floors[a].mean).map((m) => `
| condition | SA@${temp.data.repeats} | top pick stable |
|---|---|---|
| temperature 0.3 (a common default) | ${f3(T3.meanSelfAgreement)} | ${pct(T3.topPickStable)} |
| temperature 0 | ${f3(T0.meanSelfAgreement)} | ${pct(T0.topPickStable)} |
Temperature explains most of the instability, and almost no AI-visibility product discloses the temperature it queries at. But note that temperature 0 does not reach 1.0 either. On batched mixture-of-experts inference, expert routing and floating-point reduction are not deterministic, so a provider's "deterministic" mode is not deterministic in the sense a researcher means.
| model | SA | 95% CI | n questions |
|---|
Bootstrap confidence intervals over questions, not over pairs. Any model with fewer than ${MIN_Q} questions carrying repeats is reported as insufficient data rather than given a number.
These are the floors. Every claim about two models differing has to clear them.
The same weights family at two sizes, with the same prompt, the same temperature and the same hour, set against models from entirely different labs:
| comparison | agreement | 95% CI | floor | ratio |
|---|
${esc(labelOf[sizePair.B])} was the only model in this run that returned an empty answer to some questions. Re-asked directly, it returns nothing at all for them, so it is declining rather than being mis-parsed. It answered ${qB.size} of the ${qA.size} questions ${esc(labelOf[sizePair.A])} answered.
That matters, because a model which only answers what it finds easy would look more self-consistent for a reason that has nothing to do with consistency. So we checked, expecting to have to withdraw the result:
| comparison set | ${esc(labelOf[sizePair.A])} | ${esc(labelOf[sizePair.B])} |
|---|---|---|
| all questions each model answered | ${f3(floors[sizePair.A].mean)} | ${f3(floors[sizePair.B].mean)} |
| only the ${likeForLike.n} questions BOTH answered | ${f3(likeForLike.A)} | ${f3(likeForLike.B)} |
| the ${skippedByB.length} questions the smaller model skipped | ${f3(onSkipped)} | declined |
The gap is wider like-for-like, not narrower.${onSkipped !== null ? ` And on the questions the smaller model skipped, the larger one scored ${f3(onSkipped)}, above its own average of ${f3(floors[sizePair.A].mean)}: the skipped set was the easier one for the larger model, so the selection effect runs against this finding rather than producing it.` : ''} We report the refusal rate alongside the agreement figure because "answers less often, more consistently when it does" is the complete claim, and either half alone is misleading.
We probed every model this estate has actually run in a published or frozen artifact, with a positive control in each run so that a dead API key cannot be mistaken for a retired model.
| model | lab | host | state |
|---|
${retired} of ${rosterN} (${retiredPct}%) no longer answer. ${retiredList.length ? `Retired at their provider: ${retiredList.map((r) => esc(r.id)).join(', ')}.` : ''} This is why the June index cannot be re-run at any price: most of its panel no longer exists. Every published AI-visibility benchmark carries this expiry and none of them print it.
Four disclosures. Short enough to adopt, specific enough to fail:
A visibility score without a floor is a guess with a decimal point. Ours are published above, including the ones that make our own earlier work look worse. If you publish AI-visibility numbers, we would rather you adopted these four lines than cited us.
Prior work and primary sources: the Generative Engine Optimization paper (Aggarwal et al., KDD 2024) established the benchmark format this field builds on; Bing Webmaster Tools AI Performance is the first-party citation telemetry we compare against; Cloudflare Radar's crawl-to-refer ratio is the model for publishing a metric others can adopt; the KDD proceedings record carries the peer-reviewed version; and the full data release is licensed CC BY 4.0.
${esc(q)}
${esc(a)}
The finding above is also the reason we keep capturing: a series started today cannot be backfilled to June, and ${retiredPct}% of the models we were measuring in June are already gone. Our ongoing captures across the Lattice network are open to partners who want their category tracked over time rather than sampled once.
See the pages and categories we measure · Partner with us
Disclosure: Lucreya is part of the Lattice network, which sells disclosed placements on pages that AI engines cite. That is a commercial interest in this subject. Our measurements, our scripts and our raw answers are published at /research/ai-recommendation-reliability-2026/ so these findings can be checked against us, and paid placements are labelled and never affect a ranking or a verdict.