reading map: seven models, one library — /reading/models

The same 9,194 passages arranged by seven embedding models, switchable. Every
model's coordinates are Procrustes-aligned to nomic, without which the toggle
would be seven unrelated arrangements and the motion between them meaningless.

⚠️ The page states the NEIGHBOUR-AGREEMENT number, not the animation, and the
distinction is load-bearing. Every projection of 1024 dimensions into 3 is lossy
in a way that shapes what you see: measured directly, PCA and UMAP disagreed by
10x about whether whole books move together, each reporting its own arithmetic.
So the claim is computed on the raw embeddings where there is no projection to
be an artifact of (models share ~50% of each passage's 15 nearest neighbours),
and the picture illustrates that number rather than being the evidence for it.
The page says so in as many words.

Renderer gains setCoordinates(), driven from the existing render loop rather
than its own rAF so a morph cannot outlive the scene or run while scrolled away.
It writes through the single `xyz` buffer the nebula, signal and trace all read
from, so every layer moves together and nothing drifts out of registration.

`trace` is now optional. The banner and explorer always carry one; a comparison
payload legitimately does not — its subject is where passages MOVE between
models, not the order they were highlighted in.

Payload is split: a 44 KB manifest with everything shared, plus one ~145 KB
coordinate file per model fetched on demand. The per-node book/kind arrays are
identical across models, so carrying them seven times is waste, and eager
loading would be ~1.1 MB before first paint.

auto-build.sh now watches public/reading_*.json as ONE glob. The previous
OR-chain was subtly broken — with `-o` the `-printf` binds only to the last
clause, so earlier matches printed in a different format and the signature was
not stable. The glob also picks up future payloads without an edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-08-16 13:40:11 -04:00
co-authored by Claude Opus 5
parent 898c942f95
commit d3d93a982a
5 changed files with 364 additions and 11 deletions
+4
View File
@@ -15,6 +15,10 @@ public/reading_umap.json
# (`export.py --explorer`, 15/0.1 plus the kind and section layers). Tracking it would dirty # (`export.py --explorer`, 15/0.1 plus the kind and section layers). Tracking it would dirty
# the tree exactly as above. # the tree exactly as above.
public/reading_explorer.json public/reading_explorer.json
# the /reading/models comparison: a manifest plus one coordinate file per embedding model,
# fetched on demand. Same generator, same reason they must stay untracked.
public/reading_models.json
public/reading_models.*.json
.build_state .build_state
.build.lock .build.lock
.notebooks.lock .notebooks.lock
+6 -6
View File
@@ -30,12 +30,12 @@ sig=$({
git diff git diff
find "$VAULT" -name .obsidian -prune -o -type f -printf '%T@ %p\n' | sort find "$VAULT" -name .obsidian -prune -o -type f -printf '%T@ %p\n' | sort
find "$REPO/notebooks-export" -type f -printf '%T@ %p\n' 2>/dev/null | sort find "$REPO/notebooks-export" -type f -printf '%T@ %p\n' 2>/dev/null | sort
# same deal for the reading map: gitignored, written by library-rag-export.service on its # same deal for every reading map payload: gitignored, written by library-rag-export.service
# own timer, so git status/diff above cannot see it change. Both payloads — the banner's and # on its own timer, so git status/diff above cannot see them change. One glob rather than a
# the /reading explorer's — or a corpus change would rebuild one page and leave the other # list — with `-o` the `-printf` binds only to the LAST clause, so an OR-chain silently
# serving yesterday's cloud. # prints the earlier matches in a different format and the signature stops being stable.
find "$REPO/public/reading_umap.json" "$REPO/public/reading_explorer.json" \ # It also means a future payload is watched without editing this line.
-printf '%T@ %p\n' 2>/dev/null | sort find "$REPO/public" -maxdepth 1 -name 'reading_*.json' -printf '%T@ %p\n' 2>/dev/null | sort
} | sha256sum | cut -d' ' -f1) } | sha256sum | cut -d' ' -f1)
if [ "$FORCE" -eq 0 ] && [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$sig" ]; then if [ "$FORCE" -eq 0 ] && [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$sig" ]; then
+216
View File
@@ -0,0 +1,216 @@
---
/**
* The model comparison — the same 9,194 passages, arranged by seven different embedding models.
*
* import ModelCompare from '../components/ModelCompare.astro';
* <ModelCompare />
*
* Pieces:
* src/components/ModelCompare.astro ← this file
* src/lib/reading-map.js ← the renderer, SHARED with the banner and explorer
* src/styles/reading-map.css ← canvas, every --map-* token
* src/styles/reading-explorer.css ← legend and control chrome
* public/reading_models.json ← manifest; generated, NOT in git
* public/reading_models.<slug>.json ← one per model; fetched on demand
*
* ⚠️ **The picture is an illustration; the CLAIM is the neighbour-agreement number.** Every
* projection of 1024 dimensions into 3 is lossy in a way that shapes what you see — measured
* directly, PCA and UMAP disagreed by 10× about whether whole books move together, and both were
* reporting their own lens. So the defensible statement is the projection-free one carried in the
* payload (`models[].agreement`), and this page must state it rather than let the animation imply
* something stronger. See library-rag/lab/RESULTS.md.
*
* ⚠️ **Every model's coordinates are Procrustes-aligned to nomic.** Without that the toggle would
* be seven unrelated arrangements and the motion between them meaningless.
*
* ⚠️ Payloads are gitignored and generated on cyrion, exactly like the other two maps. A fresh
* checkout has none and the figure stays hidden. Regenerate with
* `uv run lab/project.py --method umap -o <checkout>/public/reading_models.json` from library-rag/.
*/
import '../styles/reading-map.css';
import '../styles/reading-explorer.css';
interface Props {
src?: string;
caption?: string | null;
}
const {
src = '/reading_models.json',
caption = 'The same 9,194 passages, placed by seven different embedding models. Switch between them to see what they disagree about.',
} = Astro.props;
---
<figure class="reading-explorer model-compare">
<div class="reading-map reading-map--explorer" data-src={src}></div>
{caption && <figcaption>{caption}</figcaption>}
<div class="model-picker" data-picker hidden>
<span class="model-picker__label">model</span>
{/* Buttons are injected at mount from the manifest, so the set can never disagree with the
payloads that actually exist. */}
</div>
<p class="model-stat" data-stat hidden></p>
<ul class="reading-legend" data-legend></ul>
</figure>
<script>
const figure = document.querySelector<HTMLElement>('.model-compare');
const container = figure?.querySelector<HTMLElement>('.reading-map');
if (container) {
try {
const { mount } = await import('../lib/reading-map.js');
const manifestUrl = container.dataset.src!;
const manifest = await (await fetch(manifestUrl)).json();
// The renderer wants `nodes.xyz`; the manifest deliberately does not carry coordinates —
// they live one per model so a visitor downloads 145 KB, not 1.1 MB. Fetch the reference
// model's file and graft it on before mounting.
const first = manifest.models.find((m: any) => m.reference) ?? manifest.models[0];
const base = manifestUrl.slice(0, manifestUrl.lastIndexOf('/') + 1);
const coords = new Map<string, number[]>();
const load = async (m: any): Promise<number[]> => {
if (!coords.has(m.slug)) {
const r = await fetch(base + m.file);
if (!r.ok) throw new Error(`${m.slug}: ${r.status}`);
coords.set(m.slug, (await r.json()).xyz);
}
return coords.get(m.slug)!;
};
manifest.nodes.xyz = await load(first);
const { build } = await import('../lib/reading-map.js');
const map = build(container, manifest, { explorer: true });
if (map) {
const picker = figure!.querySelector<HTMLElement>('[data-picker]');
const stat = figure!.querySelector<HTMLElement>('[data-stat]');
const legend = figure!.querySelector<HTMLElement>('[data-legend]');
// --- legend, same encoding the explorer uses -------------------------------------
if (legend && map.kinds.length) {
for (const k of map.kinds) {
const li = document.createElement('li');
const dot = document.createElement('i');
dot.style.background = k.color;
const name = document.createElement('b');
name.textContent = k.name;
const count = document.createElement('span');
count.className = 'reading-legend__count';
count.textContent = `${k.chunks.toLocaleString()} passages`;
li.append(dot, name, count);
legend.append(li);
}
}
// --- the finding, stated rather than implied --------------------------------------
const describe = (m: any) => {
if (!stat) return;
if (m.reference) {
stat.innerHTML =
`<b>${m.name}</b> — ${m.params}, ${m.dim} dimensions. ` +
`This is what search actually runs on; the others are measured against it.`;
} else {
const pct = Math.round((m.agreement ?? 0) * 100);
stat.innerHTML =
`<b>${m.name}</b> — ${m.params}, ${m.dim} dimensions. ` +
`Shares <b>${pct}%</b> of each passage's fifteen nearest neighbours with ` +
`${manifest.meta.reference}. ` +
`<span class="model-stat__note">Measured on the original embeddings, not on this ` +
`picture — the projection is an illustration of the number, not its source.</span>`;
}
stat.hidden = false;
};
describe(first);
// --- the toggle --------------------------------------------------------------------
if (picker) {
let active = first.slug;
let busy = false;
for (const m of manifest.models) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = m.name.replace(/:.*$/, '');
b.title = `${m.params}, dim ${m.dim}`;
b.dataset.slug = m.slug;
if (m.slug === active) b.setAttribute('aria-current', 'true');
b.addEventListener('click', async () => {
// Ignore re-clicks and clicks during a fetch: two overlapping morphs would fight
// over the same coordinate buffer and leave the cloud somewhere between models.
if (busy || m.slug === active) return;
busy = true;
b.classList.add('is-loading');
try {
map.setCoordinates(await load(m));
active = m.slug;
for (const other of picker.querySelectorAll('button')) {
other.toggleAttribute('aria-current', other === b);
}
describe(m);
} catch (err) {
console.warn('model payload unavailable:', err);
} finally {
b.classList.remove('is-loading');
busy = false;
}
});
picker.append(b);
}
picker.hidden = false;
}
}
} catch (err) {
/* fail closed: the figure stays hidden by the CSS */
console.warn('model comparison unavailable:', err);
}
}
</script>
<style>
.model-picker {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin: 1rem 0 0;
}
.model-picker[hidden] { display: none; }
.model-picker__label {
font-size: 0.78rem;
color: var(--muted);
margin-right: 0.2rem;
}
.model-picker button {
padding: 0.3rem 0.62rem;
font: inherit;
font-size: 0.78rem;
color: var(--fg);
background: transparent;
border: 1px solid var(--hair, hsla(0, 0%, 100%, 0.14));
border-radius: 0.3rem;
cursor: pointer;
}
.model-picker button:hover { border-color: var(--link-bright, #46c421); }
/* The active model is marked with a border and a weight change, never colour alone — the
kind colours in the cloud are already carrying meaning and a third colour code here would
compete with them. */
.model-picker button[aria-current] {
border-color: var(--link-bright, #46c421);
font-weight: 700;
}
.model-picker button.is-loading { opacity: 0.5; cursor: progress; }
.model-stat {
margin: 0.75rem 0 0;
font-size: 0.82rem;
line-height: 1.55;
color: var(--muted);
}
.model-stat[hidden] { display: none; }
.model-stat b { color: var(--fg); font-weight: 600; }
.model-stat__note { display: block; margin-top: 0.2rem; opacity: 0.75; font-size: 0.95em; }
</style>
+67 -5
View File
@@ -149,6 +149,12 @@ export function build(container, data, { explorer = false } = {}) {
const xyz = Float32Array.from(data.nodes.xyz); const xyz = Float32Array.from(data.nodes.xyz);
const nodeCount = xyz.length / 3; const nodeCount = xyz.length / 3;
// ⚠️ `trace` is optional. The banner and explorer always carry one; the model-comparison
// payload legitimately does not — its subject is where passages MOVE between models, not the
// order they were highlighted in. Defaulting here beats making that caller fabricate an empty
// one, and beats seven `data.trace &&` guards scattered through the draw loop.
if (!Array.isArray(data.trace)) data.trace = [];
// --- the nebula: every chunk, one draw call --------------------------------------------- // --- the nebula: every chunk, one draw call ---------------------------------------------
const nebulaGeo = new THREE.BufferGeometry(); const nebulaGeo = new THREE.BufferGeometry();
nebulaGeo.setAttribute('position', new THREE.BufferAttribute(xyz, 3)); nebulaGeo.setAttribute('position', new THREE.BufferAttribute(xyz, 3));
@@ -505,6 +511,56 @@ export function build(container, data, { explorer = false } = {}) {
return isolated; return isolated;
} }
// --- model switching (comparison page) -----------------------------------------------------
/**
* Swap in another model's coordinates for the same points.
*
* ⚠️ **Only legitimate because every model's payload is Procrustes-aligned to the same
* reference** (`lab/project.py`). Without that the clouds share no frame, and what looks like a
* model disagreeing is mostly an arbitrary rotation. Never point this at unaligned coordinates.
*
* `duration` of 0 cuts; anything else eases between the two layouts. Which reads better is an
* open question — p90 displacement is 0.220.41 of cloud diameter, so a good fraction of points
* travel a long way and a morph risks looking like a shuffle rather than a drift.
*
* The trace and signal layers follow the nebula, because they index the same rows.
*/
let morph = null;
function setCoordinates(next, duration = 900) {
if (!next || next.length !== xyz.length) return false;
const from = Float32Array.from(xyz);
const to = Float32Array.from(next);
const t0 = performance.now();
const apply = (t) => {
// Eased in place: `xyz` is the single source the nebula, signal and trace all read from,
// so writing here moves every layer together and nothing can drift out of registration.
const e = t >= 1 ? 1 : 1 - Math.pow(1 - t, 3);
for (let i = 0; i < xyz.length; i++) xyz[i] = from[i] + (to[i] - from[i]) * e;
nebulaGeo.attributes.position.needsUpdate = true;
signalNodes.forEach((n, i) => {
signalPos[i * 3] = xyz[n * 3];
signalPos[i * 3 + 1] = xyz[n * 3 + 1];
signalPos[i * 3 + 2] = xyz[n * 3 + 2];
});
signalGeo.attributes.position.needsUpdate = true;
data.trace.forEach((tr, i) => {
tracePos[i * 3] = xyz[tr.node * 3];
tracePos[i * 3 + 1] = xyz[tr.node * 3 + 1];
tracePos[i * 3 + 2] = xyz[tr.node * 3 + 2];
});
traceGeo.attributes.position.needsUpdate = true;
};
if (!duration || reduced) { morph = null; apply(1); return true; }
morph = (now) => {
const t = (now - t0) / duration;
apply(t);
if (t >= 1) morph = null;
};
return true;
}
// --- resize ------------------------------------------------------------------------------- // --- resize -------------------------------------------------------------------------------
// ⚠️ Mark ready BEFORE measuring. The fail-closed CSS hides the container until `data-ready` // ⚠️ Mark ready BEFORE measuring. The fail-closed CSS hides the container until `data-ready`
// is set, so a container measured first reports 0×0, the early return leaves the canvas at // is set, so a container measured first reports 0×0, the early return leaves the canvas at
@@ -541,6 +597,9 @@ export function build(container, data, { explorer = false } = {}) {
// homepage, and a continuously rotating scene is exactly what a reader who asked for // homepage, and a continuously rotating scene is exactly what a reader who asked for
// reduced motion is asking not to get. The cloud still renders — it just holds still. // reduced motion is asking not to get. The cloud still renders — it just holds still.
if (!paused && !reduced) world.rotation.y += ORBIT_RATE * dt; if (!paused && !reduced) world.rotation.y += ORBIT_RATE * dt;
// Driven from the render loop rather than its own rAF, so a morph cannot outlive the scene
// or keep running while the page is scrolled away (the IntersectionObserver parks this loop).
if (morph) morph(now);
elapsed += dt; elapsed += dt;
const cycle = DRAW_SECONDS + HOLD_SECONDS; const cycle = DRAW_SECONDS + HOLD_SECONDS;
@@ -549,12 +608,14 @@ export function build(container, data, { explorer = false } = {}) {
? total ? total
: Math.max(1, Math.min(total, Math.round((elapsed / DRAW_SECONDS) * total))); : Math.max(1, Math.min(total, Math.round((elapsed / DRAW_SECONDS) * total)));
traceGeo.setDrawRange(0, drawn); if (total) {
const h = (drawn - 1) * 3; traceGeo.setDrawRange(0, drawn);
head.geometry.attributes.position.setXYZ(0, tracePos[h], tracePos[h + 1], tracePos[h + 2]); const h = (drawn - 1) * 3;
head.geometry.attributes.position.needsUpdate = true; head.geometry.attributes.position.setXYZ(0, tracePos[h], tracePos[h + 1], tracePos[h + 2]);
head.geometry.attributes.position.needsUpdate = true;
}
if (drawn - 1 !== shownIndex) { if (total && drawn - 1 !== shownIndex) {
shownIndex = drawn - 1; shownIndex = drawn - 1;
dateEl.textContent = fmtDate(data.trace[shownIndex].t); dateEl.textContent = fmtDate(data.trace[shownIndex].t);
} }
@@ -616,6 +677,7 @@ export function build(container, data, { explorer = false } = {}) {
/** Explorer only. A no-op returning null when the payload carries no kind layer. */ /** Explorer only. A no-op returning null when the payload carries no kind layer. */
isolateBook, isolateBook,
get isolatedBook() { return isolated; }, get isolatedBook() { return isolated; },
setCoordinates,
/** /**
* What a legend needs, COUNTED FROM THE PAYLOAD rather than hardcoded. A kind's share moves * What a legend needs, COUNTED FROM THE PAYLOAD rather than hardcoded. A kind's share moves
+71
View File
@@ -0,0 +1,71 @@
---
import Base from '../../layouts/Base.astro';
import ModelCompare from '../../components/ModelCompare.astro';
---
<Base
title="Seven models, one library"
description="The same 9,194 passages placed by seven different embedding models — three of which retrieve exactly as well as each other, and none of which agree about where anything goes."
>
<h1>Seven models, one library</h1>
<p>
<a href="/reading">The reading map</a> shows my library arranged by one embedding model —
the one search actually runs on. This page asks a different question: <em>would another
model arrange it the same way?</em>
</p>
<ModelCompare />
<h2>They retrieve the same. They do not agree.</h2>
<p>
I scored all seven against 48 hand-written retrieval questions, and the boring result came
first: they are nearly all the same. <code>embeddinggemma</code> ties the incumbent exactly.
Two 568-million-parameter models land within two questions of a 137-million one. Four times
the parameters bought nothing measurable.
</p>
<p>
So I asked whether they at least <em>organise</em> the library the same way, and they
emphatically do not. Take any passage, find its fifteen nearest neighbours under two
different models, and compare the lists: they share about <strong>half</strong>. Between the
incumbent and the weakest model, barely a third.
</p>
<p>
What they <em>do</em> agree on is that this is a map of books. Under every single model,
roughly 7088% of a passage's neighbours come from the same book it does — against 6% if
they were drawn at random. Every model finds the same continents. They disagree about the
streets.
</p>
<h2>How much of this is real</h2>
<p class="model-note">
Squashing 1,024 dimensions into 3 loses most of the information, and it loses it differently
depending on how you squash. When I measured whether whole books move together, two standard
methods disagreed by a factor of ten on identical data — each one confidently reporting a
property of its own arithmetic rather than of the models. So the number quoted above is
computed on the raw embeddings, where there is no projection to be an artifact of, and this
animation is an illustration of that number rather than the evidence for it.
</p>
<p class="model-note">
Some of the motion you see when switching is the layout algorithm rather than the models,
for the same reason. The clouds are rigidly aligned to a common frame first, which removes
the arbitrary rotation but not that last ambiguity. Believe the percentage; enjoy the movement.
</p>
<p class="model-note">
Working notes, including the two occasions this measurement caught me publishing an artifact
as a finding, are in <code>library-rag/lab/RESULTS.md</code>.
</p>
</Base>
<style>
.model-note {
font-size: 0.85rem;
color: var(--muted);
}
</style>