File size: 13,311 Bytes
c126239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
#!/usr/bin/env node
/**
 * Offline k-means centroid computation for the k-SemStamp demo.
 *
 * Embeds a corpus with the SAME encoder, the SAME task prefix and the SAME
 * call shape the browser uses (EmbeddingGemma via AutoModel ->
 * `sentence_embedding`). Any difference here puts the centroids in a
 * different space from the runtime assignments, and every cluster decision
 * becomes noise.
 *
 * Corpus: wikitext-2 sentences from the HF datasets server, plus narrative and
 * Korean sentences so the clusters cover what the demo actually generates.
 *
 * Usage: node scripts/compute-centroids.mjs
 */

import { AutoModel, AutoTokenizer } from '@huggingface/transformers';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'public', 'centroids', 'embeddinggemma-k8.json');
const ENCODER = 'onnx-community/embeddinggemma-300m-ONNX';
const CLUSTERING_PREFIX = 'task: clustering | query: ';
/**
 * Quantization changes the geometry: q4 and fp32 embeddings of the same
 * sentence sit at cosine ~0.98, enough to flip cluster assignments near a
 * boundary. The browser must use this same dtype, so it travels with the
 * centroids and is checked at load.
 */
const DTYPE = 'q4';
const K = 8;
const TARGET_SENTENCES = 400;
const SEED = 12345;

// Deterministic PRNG (mulberry32) so centroids are reproducible.
function mulberry32(seed) {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

async function fetchWikitextSentences() {
  const sentences = [];
  for (let offset = 0; offset < 1200 && sentences.length < TARGET_SENTENCES; offset += 100) {
    const url = `https://datasets-server.huggingface.co/rows?dataset=Salesforce%2Fwikitext&config=wikitext-2-raw-v1&split=train&offset=${offset}&length=100`;
    const res = await fetch(url);
    if (!res.ok) throw new Error(`datasets-server HTTP ${res.status}`);
    const json = await res.json();
    for (const row of json.rows ?? []) {
      const text = (row.row?.text ?? '').trim();
      if (!text || text.startsWith('=')) continue;
      for (const s of text.split(/(?<=[.!?])\s+/)) {
        const t = s.trim();
        if (t.length >= 40 && t.length <= 300 && /[.!?]$/.test(t)) sentences.push(t);
        if (sentences.length >= TARGET_SENTENCES) break;
      }
      if (sentences.length >= TARGET_SENTENCES) break;
    }
  }
  return sentences;
}

const FALLBACK = [
  'The committee approved the budget after a lengthy debate about infrastructure spending.',
  'Photosynthesis converts sunlight, water, and carbon dioxide into glucose and oxygen.',
  'The orchestra performed the symphony with remarkable precision and emotional depth.',
  'Quarterly earnings exceeded analyst expectations despite rising material costs.',
  'The hikers reached the summit just before the storm clouds rolled in.',
  'Researchers discovered a new species of frog in the remote rainforest valley.',
  'The museum unveiled a restored painting that had been hidden for decades.',
  'Global temperatures have risen steadily over the past century, reshaping ecosystems.',
  'The chef combined traditional techniques with unexpected local ingredients.',
  'Engineers tested the bridge design under simulated earthquake conditions.',
  'The novel follows three generations of a family through war and migration.',
  'Voter turnout reached a record high in several coastal districts this year.',
  'The spacecraft transmitted its first images of the icy moon at dawn.',
  'Local farmers adopted drip irrigation to cope with the prolonged drought.',
  'The startup raised new funding to expand its battery recycling operations.',
  'Archaeologists uncovered pottery fragments dating back three thousand years.',
  'The goalkeeper made a spectacular save in the final minute of the match.',
  'New regulations require companies to disclose their supply chain emissions.',
  'The pianist practiced the difficult passage until it sounded effortless.',
  'Coral reefs support a quarter of all marine species despite covering little area.',
];

function kmeans(vectors, k, rng) {
  const n = vectors.length;
  const dim = vectors[0].length;
  // k-means++ init
  const centroids = [vectors[Math.floor(rng() * n)].slice()];
  while (centroids.length < k) {
    const d2 = vectors.map((v) => {
      let best = Infinity;
      for (const c of centroids) {
        let dot = 0;
        for (let i = 0; i < dim; i++) dot += v[i] * c[i];
        const d = 1 - dot;
        if (d < best) best = d;
      }
      return best * best;
    });
    const sum = d2.reduce((a, b) => a + b, 0);
    let r = rng() * sum;
    let idx = 0;
    for (; idx < n - 1; idx++) {
      r -= d2[idx];
      if (r <= 0) break;
    }
    centroids.push(vectors[idx].slice());
  }

  const assign = new Array(n).fill(0);
  for (let iter = 0; iter < 100; iter++) {
    let changed = 0;
    for (let i = 0; i < n; i++) {
      let best = 0;
      let bestD = Infinity;
      for (let c = 0; c < k; c++) {
        let dot = 0;
        for (let j = 0; j < dim; j++) dot += vectors[i][j] * centroids[c][j];
        const d = 1 - dot;
        if (d < bestD) {
          bestD = d;
          best = c;
        }
      }
      if (assign[i] !== best) {
        assign[i] = best;
        changed++;
      }
    }
    // recompute (spherical: mean then renormalize)
    for (let c = 0; c < k; c++) {
      const sum = new Array(dim).fill(0);
      let count = 0;
      for (let i = 0; i < n; i++) {
        if (assign[i] !== c) continue;
        count++;
        for (let j = 0; j < dim; j++) sum[j] += vectors[i][j];
      }
      if (count === 0) continue;
      let norm = 0;
      for (let j = 0; j < dim; j++) norm += sum[j] * sum[j];
      norm = Math.sqrt(norm) || 1;
      for (let j = 0; j < dim; j++) centroids[c][j] = sum[j] / norm;
    }
    if (changed === 0) break;
  }
  const sizes = new Array(k).fill(0);
  for (const a of assign) sizes[a]++;
  return { centroids, sizes };
}

// Narrative-register sentences: the LLM demo mostly generates stories, and
// clusters trained on encyclopedic text alone would collapse story sentences
// into one or two clusters, starving the rejection sampler.
const NARRATIVE = [
  'The old keeper climbed the spiral staircase as the wind howled outside.',
  'She had never seen a light quite like the one glowing beneath the waves.',
  'Every evening he wrote a single line in his weather-beaten journal.',
  'The village children whispered stories about the tower on the cliff.',
  'A strange ship appeared on the horizon just before midnight.',
  'His lantern flickered, and for a moment the darkness felt alive.',
  'The storm had passed, leaving the shore littered with pale driftwood.',
  'She wrapped the mysterious object in cloth and hid it beneath the floorboards.',
  'Nobody in town believed him, but the light kept returning every night.',
  'The fog rolled in thick, swallowing the beam before it reached the water.',
  'He remembered his grandfather saying the sea keeps what it takes.',
  'At dawn the tide revealed a door in the rocks that had never been there.',
  'Her heart pounded as the signal repeated: three flashes, then silence.',
  'The keeper brewed his tea and watched the horizon with tired eyes.',
  'Something metallic glinted in the sand where the gulls had gathered.',
  'The letters stopped coming in October, and the winter felt endless.',
  'He rowed out at first light, following the trail of green phosphorescence.',
  'The melody drifted up from the cellar, soft and impossibly old.',
  'They said the previous keeper had vanished without taking his coat.',
  'By morning the writing on the glass had faded, but she remembered every word.',
  'The captain lowered his voice and told them what he had seen in the trench.',
  'A key turned in a lock that no one had opened for forty years.',
  'The child drew the same spiral again and again in her notebook.',
  'When the generator failed, the stars felt suddenly very close.',
  'He counted the ships as they passed, and one of them counted him back.',
  'The tide pool held a color that did not belong to this coast.',
  'Her boots left the only prints on the long gray beach.',
  'The radio crackled with a voice reciting coordinates that did not exist.',
  'In the attic they found charts of a coastline no map had ever shown.',
  'The bell buoy rang twice, though the sea was perfectly still.',
];

// The encoder is multilingual, so the corpus should be too: clusters trained
// only on English would still "work" on Korean, but they would be describing
// a region of the space the Korean sentences barely enter.
const KOREAN = [
  '๋ ˆ๋ชฌ ์น˜ํ‚จ ํŒŒ์Šคํƒ€ ๋ ˆ์‹œํ”ผ๋ฅผ ์•Œ๋ ค์ค˜.',
  '์˜ฌ๋ฆฌ๋ธŒ์œ ์— ๋งˆ๋Š˜์„ ๋ณถ๋‹ค๊ฐ€ ๋‹ญ๊ฐ€์Šด์‚ด์„ ๋„ฃ๊ณ  ๋…ธ๋ฆ‡ํ•˜๊ฒŒ ์ตํžŒ๋‹ค.',
  '๋ฉด์€ ์†Œ๊ธˆ์„ ๋„‰๋„‰ํžˆ ๋„ฃ์€ ๋ฌผ์— ์•Œ ๋ดํ…Œ๋กœ ์‚ถ๋Š”๋‹ค.',
  '๋งˆ์ง€๋ง‰์— ๋ ˆ๋ชฌ์ฆ™๊ณผ ํŒŒ์Šฌ๋ฆฌ๋ฅผ ๋ฟŒ๋ฆฌ๋ฉด ํ–ฅ์ด ์‚ด์•„๋‚œ๋‹ค.',
  '๋“ฑ๋Œ€์ง€๊ธฐ๋Š” ๋งค์ผ ๋ฐค ์ˆ˜ํ‰์„ ์„ ๋ฐ”๋ผ๋ณด๋ฉฐ ๊ธฐ๋ก์„ ๋‚จ๊ฒผ๋‹ค.',
  'ํญํ’์ด ์ง€๋‚˜๊ฐ„ ์•„์นจ, ํ•ด๋ณ€์—๋Š” ๋‚ฏ์„  ๋‚˜๋ฌด ์กฐ๊ฐ๋“ค์ด ๋ฐ€๋ ค์™€ ์žˆ์—ˆ๋‹ค.',
  '๋งˆ์„ ์‚ฌ๋žŒ๋“ค์€ ์ ˆ๋ฒฝ ์œ„ ํƒ‘์— ๋Œ€ํ•œ ์ด์•ผ๊ธฐ๋ฅผ ์†Œ๊ณค๊ฑฐ๋ ธ๋‹ค.',
  '๊ทธ๋Š” ๋“ฑ๋ถˆ์„ ๋“ค๊ณ  ๋‚˜์„  ๊ณ„๋‹จ์„ ์ฒœ์ฒœํžˆ ์˜ฌ๋ผ๊ฐ”๋‹ค.',
  '์—ฐ๊ตฌ์ง„์€ ์ƒˆ๋กœ์šด ๊ฐœ๊ตฌ๋ฆฌ ์ข…์„ ์—ด๋Œ€ ์šฐ๋ฆผ์—์„œ ๋ฐœ๊ฒฌํ–ˆ๋‹ค.',
  '์ง€๋‚œ 100๋…„ ๋™์•ˆ ์ง€๊ตฌ์˜ ํ‰๊ท  ๊ธฐ์˜จ์€ ๊พธ์ค€ํžˆ ์ƒ์Šนํ–ˆ๋‹ค.',
  '์ด๋ฒˆ ๋ถ„๊ธฐ ์‹ค์ ์€ ์›์ž์žฌ ๊ฐ€๊ฒฉ ์ƒ์Šน์—๋„ ์˜ˆ์ƒ์น˜๋ฅผ ์›ƒ๋Œ์•˜๋‹ค.',
  '์œ„์›ํšŒ๋Š” ์˜ค๋žœ ๋…ผ์˜ ๋์— ์˜ˆ์‚ฐ์•ˆ์„ ์Šน์ธํ–ˆ๋‹ค.',
  '๋ฐ•๋ฌผ๊ด€์€ ์ˆ˜์‹ญ ๋…„๊ฐ„ ๋ณด๊ด€๋ผ ์žˆ๋˜ ๊ทธ๋ฆผ์„ ๋ณต์›ํ•ด ๊ณต๊ฐœํ–ˆ๋‹ค.',
  '๊ด‘ํ•ฉ์„ฑ์€ ํ–‡๋น›๊ณผ ๋ฌผ, ์ด์‚ฐํ™”ํƒ„์†Œ๋ฅผ ํฌ๋„๋‹น๊ณผ ์‚ฐ์†Œ๋กœ ๋ฐ”๊พผ๋‹ค.',
  '๊ธฐ์ˆ ์ด ๋ฐœ์ „ํ•˜๋ฉด์„œ ๋ฒˆ์—ญ์˜ ํ’ˆ์งˆ๋„ ํฌ๊ฒŒ ์ข‹์•„์กŒ๋‹ค.',
  '๊ทธ ์†Œ์„ค์€ ์ „์Ÿ๊ณผ ์ด์ฃผ๋ฅผ ๊ฒช์€ ์„ธ ์„ธ๋Œ€์˜ ์ด์•ผ๊ธฐ๋ฅผ ๋”ฐ๋ผ๊ฐ„๋‹ค.',
  '์•„์ด๋Š” ๊ณต์ฑ…์— ๊ฐ™์€ ๋‚˜์„ ์„ ๋ช‡ ๋ฒˆ์ด๊ณ  ๋‹ค์‹œ ๊ทธ๋ ธ๋‹ค.',
  '์ƒˆ๋ฒฝ์ด ๋˜์ž ๋ฐ€๋ฌผ์ด ๋น ์ง€๋ฉฐ ๋ฐ”์œ„ ์‚ฌ์ด์˜ ๋ฌธ์ด ๋“œ๋Ÿฌ๋‚ฌ๋‹ค.',
  '์—”์ง€๋‹ˆ์–ด๋“ค์€ ์ง€์ง„์„ ๊ฐ€์ •ํ•œ ์กฐ๊ฑด์—์„œ ๋‹ค๋ฆฌ ์„ค๊ณ„๋ฅผ ์‹œํ—˜ํ–ˆ๋‹ค.',
  '์ฃผ๋ง ๋†์žฅ์—์„œ๋Š” ๋ฌผ์„ ์•„๋ผ๊ธฐ ์œ„ํ•ด ์ ์  ๊ด€์ˆ˜๋ฅผ ๋„์ž…ํ–ˆ๋‹ค.',
];

async function main() {
  let sentences;
  let corpus;
  try {
    const wiki = await fetchWikitextSentences();
    sentences = [...wiki, ...NARRATIVE, ...KOREAN];
    corpus = `wikitext-2-raw-v1 (${wiki.length}) + narrative (${NARRATIVE.length}) + Korean (${KOREAN.length})`;
    console.log(`Fetched ${wiki.length} wikitext + ${NARRATIVE.length} narrative + ${KOREAN.length} Korean`);
  } catch (e) {
    console.warn('wikitext fetch failed, using built-in fallback corpus:', e.message);
    sentences = [...FALLBACK, ...NARRATIVE, ...KOREAN];
    corpus = `built-in fallback (${sentences.length} sentences)`;
  }

  console.log('Loading EmbeddingGemma...');
  const tokenizer = await AutoTokenizer.from_pretrained(ENCODER);
  const model = await AutoModel.from_pretrained(ENCODER, { dtype: DTYPE });

  console.log(`Embedding ${sentences.length} sentences...`);
  const vectors = [];
  const BATCH = 16;
  for (let i = 0; i < sentences.length; i += BATCH) {
    const batch = sentences.slice(i, i + BATCH).map((s) => CLUSTERING_PREFIX + s);
    const inputs = await tokenizer(batch, { padding: true });
    const { sentence_embedding } = await model(inputs);
    const [n, d] = sentence_embedding.dims;
    for (let r = 0; r < n; r++) {
      vectors.push(Array.from(sentence_embedding.data.slice(r * d, (r + 1) * d)));
    }
    console.log(`  ${Math.min(i + BATCH, sentences.length)}/${sentences.length}`);
  }

  // Mean-center before clustering. Sentence-transformer embeddings share a
  // large common component; without removing it every English sentence sits
  // in one dominant cone and k-means degenerates into a single attractor
  // (observed: all candidates landing in C0). The same mean is subtracted at
  // assignment time in the browser.
  const dim = vectors[0].length;
  const meanVec = new Array(dim).fill(0);
  for (const v of vectors) for (let j = 0; j < dim; j++) meanVec[j] += v[j] / vectors.length;
  const centered = vectors.map((v) => {
    const c = v.map((x, j) => x - meanVec[j]);
    const norm = Math.hypot(...c) || 1;
    return c.map((x) => x / norm);
  });

  console.log('Running spherical k-means on mean-centered embeddings...');
  const rng = mulberry32(SEED);
  const { centroids, sizes } = kmeans(centered, K, rng);
  console.log('Cluster sizes:', sizes);

  const payload = {
    vectors: centroids.map((c) => c.map((x) => Number(x.toFixed(6)))),
    mean: meanVec.map((x) => Number(x.toFixed(6))),
    dim,
    prefix: CLUSTERING_PREFIX,
    dtype: DTYPE,
    encoder: ENCODER,
    corpus,
    k: K,
    seed: SEED,
    generatedAt: new Date().toISOString(),
  };
  mkdirSync(dirname(OUT), { recursive: true });
  writeFileSync(OUT, JSON.stringify(payload));
  console.log(`Wrote ${OUT}`);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});