/** * Composition root. * * The only module permitted to import from every layer, and the only place * where the pure core is joined to the impure edge. Everything above this file * is testable without a browser; everything below it is a browser detail. * * Read this file to understand the dataflow. It is deliberately linear: * * load corpus -> restore state -> render -> observe -> re-rank -> render * ^ | * +------------------------------------+ */ import { loadCorpus } from '../adapters/corpus.js'; import { createStatePort } from '../adapters/storage.js'; import { createObserver } from '../adapters/observer.js'; import { rank, applyEvent, recordImpressions, initialState, DEFAULT_CONFIG } from '../core/rank/rank.js'; import { applyOrder, bindWhy } from '../ui/feed.js'; import { createTerrain } from '../ui/terrain.js'; /** Re-rank at most this often. Re-ordering under the reader's cursor is hostile. */ const RERANK_IDLE_MS = 2600; /** Yield to the main thread, with a fallback for engines without the Scheduler API. */ async function yieldToMain() { if ('scheduler' in globalThis && 'yield' in globalThis.scheduler) { await globalThis.scheduler.yield(); } else { await new Promise((r) => setTimeout(r, 0)); } } async function boot() { const feedEl = document.querySelector('[data-feed]'); if (!feedEl) return; const { items, dim } = await loadCorpus( feedEl.dataset.corpus ?? '/content/corpus.json', feedEl.dataset.embeddings ?? '/content/embeddings.bin', ); const index = new Map(items.map((i) => [i.id, i])); const indexObj = Object.fromEntries(index); const topics = [...new Set(items.map((i) => i.topic))].sort(); const store = createStatePort(dim); let state = store.load() ?? initialState(dim); // Session seed: stable for this tab so a re-render is not a re-shuffle, but // different between visitors so the site is not identical for everyone. const seed = (() => { try { const k = 'ftj.seed'; const found = sessionStorage.getItem(k); if (found) return Number(found); const s = (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0; sessionStorage.setItem(k, String(s)); return s; } catch { return 20260907; } })(); let round = 0; let dirty = false; let timer = 0; // The terrain needs the projected manifold — the 2D shape of the corpus. // It is a separate fetch because the feed is fully usable without it: if this // request fails the page loses its drawing, not its content. const canvas = document.querySelector('[data-instrument]'); let terrain = null; let manifold = null; if (canvas) { try { const res = await fetch(feedEl.dataset.manifold ?? '/content/manifold.json', { credentials: 'omit' }); if (res.ok) { manifold = await res.json(); terrain = createTerrain(canvas, manifold); } } catch { /* No drawing. The document stands on its own. */ } } /** itemId -> its position on the manifold. */ const place = new Map((manifold?.points ?? []).map((p) => [p.id, p])); /** Posterior means, normalised — "how much does the model like each topic". */ function topicMix() { const means = topics.map((t) => { const p = state.topics[t] ?? { alpha: DEFAULT_CONFIG.priorAlpha, beta: DEFAULT_CONFIG.priorBeta }; return p.alpha / (p.alpha + p.beta); }); const sum = means.reduce((s, m) => s + m, 0) || 1; return means.map((m) => m / sum); } /** * Where on the manifold should the light fall? * * Not a projection of the interest vector — projecting a 384-dimensional * vector into a 2D layout that was fitted to *distances* would put the light * somewhere arbitrary. Instead, take the terrain positions of the entries the * model currently ranks highest and use their weighted centroid. The light * lands where the work it is recommending actually sits, which is both * correct and legible: you can see it move to the region it is drawn to. * * @param {Array<{item: object}>} ranked */ function focusOf(ranked) { let sx = 0; let sy = 0; let sw = 0; for (let i = 0; i < Math.min(6, ranked.length); i += 1) { const p = place.get(ranked[i].item.id); if (!p) continue; const w = Math.exp(-i / 2.5); // the top entry dominates sx += p.x * w; sy += p.y * w; sw += w; } if (sw === 0) return null; return { x: sx / sw, y: sy / sw }; } async function rerank({ animate = true } = {}) { const ranked = rank(items, state, { nowMs: Date.now(), seed, round, limit: items.length }); round += 1; await yieldToMain(); applyOrder(feedEl, ranked, { animate }); state = recordImpressions(state, ranked.slice(0, 6).map((r) => r.item.id)); store.save(state); const focus = focusOf(ranked); if (focus) terrain?.update({ focus }); renderState(); } const stateEl = document.querySelector('[data-state-topics]'); function renderState() { if (!stateEl) return; const mix = topicMix(); stateEl.innerHTML = topics.map((t, i) => { const p = state.topics[t] ?? { alpha: 1, beta: 1 }; const evidence = p.alpha + p.beta - 2; return `