How it works
Room Map
A room full of people, one phrase each, and a map where the similar phrases sit together. Nothing is computed on a server: each phone turns its own phrase into numbers, the database only passes them on, and the big screen lays them out. Below are the nine ideas that make it work, each with the lines of code that carry it, then the results, the limits and how to reproduce them.
- Representation learning
- Dimensionality reduction
- Clustering
- On-device inference
- Realtime databases
- Error-correcting codes
One phrase's journey
- Typea phone turns the phrase into 384 numbers (or 1056 with the fallback)
- Sendthe numbers go into a shared table; every open map hears about it
- ReducePCA to 10 numbers, then t-SNE to a spot on a flat sheet
- Groupk-means, with k chosen by silhouette, and two words per group
The ideas
- 01A phrase as an arrow
- 02PCA first, then t-SNE
- 03What the map keeps and what it lies about
- 04How many groups? Ask the silhouette
- 05Naming a group
- 06The model runs on the phone
- 07New dots drop in live
- 08A database anyone may read and nobody may wreck
- 09The QR code, from scratch
- →Results
- →Limits
- →Reproduce
- →References
- →Read further
A phrase as an arrow
Embeddings and cosine similarity
An embedding is a list of numbers that stands for a piece of text, made so that texts with similar meaning get similar lists. Think of each list as an arrow from the origin in a space with hundreds of directions. Two arrows that point the same way describe similar things; two at right angles have nothing to do with each other. The number that says how aligned two arrows are is the cosine similarity: the cosine of the angle between them, 1 for the same direction, 0 for a right angle. If every arrow is scaled to length 1 first, the cosine is just the dot product, which is why every vector in this project is normalised as soon as it arrives.
The real embedder is a small sentence model, all-MiniLM-L6-v2, giving 384 numbers per phrase; it learned from millions of sentence pairs which phrases belong together, which is why “jazz piano” and “learning guitar” land near each other without sharing a word. It needs a 23 MB download. When a phone cannot get it, a built-in fallback makes a 1056-number vector with no model at all, and the Python below is that fallback, line for line the same as the JavaScript:
def embed(text: str) -> np.ndarray:
"""The unit vector for one phrase (all zeros for an empty phrase)."""
words = np.zeros(D_WORD)
tris = np.zeros(D_TRI)
topics = np.zeros(D_TOPIC)
for tok in tokenize(text):
h = fnv1a(tok)
words[h % D_WORD] += word_weight(tok) * (1.0 if (h >> 9) & 1 else -1.0)
s = " " + tok + " "
for i in range(len(s) - 2):
h = fnv1a(s[i:i + 3])
tris[h % D_TRI] += 1.0 if (h >> 9) & 1 else -1.0
for t in WORD_TOPICS.get(tok, ()):
topics[t] += 1.0
v = np.concatenate([BLOCK_WEIGHTS[0] * _unit(words), BLOCK_WEIGHTS[1] * _unit(tris), BLOCK_WEIGHTS[2] * _unit(topics)])
return _unit(v)
- Every word is hashed to one of 512 slots with a sign, so the vector never needs a dictionary of every possible word. The weight is the only “knowledge”: function words (
the,my) count 0.1, everyday words 0.45, topical words from a 900-word list 1.2, and unknown words 1.5, since a rare word says the most about a phrase. - Character trigrams of each word (
cl,cli,lim…) go into another 512 slots. They make climbing and climb neighbours even though the word slots see two different words. - A hand-written table of 32 topics (food, music, code, …) adds the only real meaning the fallback has: “ramen” and “croissant” share the food dimension. It is honest about what it is: spelling plus a small lookup table, not a model of language.
- The three blocks are each scaled to length 1, weighted, and the whole vector scaled to length 1 again, so cosine similarity is a dot product.
A MiniLM vector and a fallback vector live in different spaces; comparing them would be meaningless. Every entry stores which embedder made it, the map only draws the majority kind, and the host can redo every phrase with one embedder on their laptop (which has the model) and write the new vectors back.
PCA first, then t-SNE
From 384 numbers to a spot on a sheet
A screen has two directions and an embedding has hundreds, so most of the information has to be thrown away; the art is in choosing what to keep. It happens in two steps.
PCA (principal component analysis) finds the ten directions along which this particular room's arrows actually differ, and keeps the position along those. Most of the 384 directions carry noise that is the same for everyone; dropping them makes the next step faster and steadier. With 500 or fewer phrases the eigenproblem is solved on the small n × n Gram matrix rather than the big covariance matrix (numpy's eigh in Python, Jacobi rotations in the browser). The ten components keep 38% of the sample room's variance; the first two alone keep only 10%, which is why PCA alone makes a poor map (section 03).
t-SNE (van der Maaten & Hinton, 2008) then asks a different question: not “which directions matter?” but “who are each point's neighbours?”. For every phrase it works out how likely every other phrase is to be picked as its neighbour in the 10-dimensional space, using a bell curve whose width is tuned per point so that each has about 20 effective neighbours (the perplexity):
def affinities(D2: np.ndarray, perplexity: float, tol: float = 1e-5, iters: int = 50) -> np.ndarray:
"""Symmetric joint probabilities P from squared distances, one Gaussian width per point found by
bisection so that the entropy of its row equals log(perplexity)."""
n = D2.shape[0]
P = np.zeros((n, n))
logU = np.log(perplexity)
for i in range(n):
idx = np.concatenate([np.arange(i), np.arange(i + 1, n)])
Di = D2[i, idx]
beta, lo, hi = 1.0, -np.inf, np.inf
for _ in range(iters):
Pi = np.exp(-Di * beta)
sumP = max(float(np.sum(Pi)), 1e-12)
H = np.log(sumP) + beta * float(np.sum(Di * Pi)) / sumP
Hdiff = H - logU
if abs(Hdiff) < tol:
break
if Hdiff > 0:
lo = beta
beta = beta * 2.0 if hi == np.inf else (beta + hi) / 2.0
else:
hi = beta
beta = beta / 2.0 if lo == -np.inf else (beta + lo) / 2.0
P[i, idx] = Pi / sumP
P = (P + P.T) / (2.0 * n)
return np.maximum(P, 1e-12)
- A bisection on the bell curve's width β until the row's entropy matches log(perplexity): 50 halvings at most, usually far fewer. Every point gets its own width, so a lonely phrase and one in a crowd both end up with about the same number of neighbours.
- The matrix is symmetrised and floored at 10⁻¹², so every pair has at least a whisper of attraction and the gradient below never divides by zero.
Then it moves points around on the sheet so that the same kind of neighbour probabilities on the sheet, computed with a heavy-tailed Student-t curve instead of a bell curve, match the ones from the 10-dimensional space as closely as possible. The mismatch is a KL divergence, and the points slide downhill on it:
def step(self, iters: int = 1) -> None:
n = self.Y.shape[0]
for _ in range(iters):
early = self.it < EARLY_ITERS
Peff = self.P * EXAGGERATION if early else self.P
momentum = 0.5 if early else 0.8
Q, num = self.q_and_num()
PQ = (Peff - Q) * num
# dY_i = 4 * sum_j PQ_ij (y_i - y_j)
dY = 4.0 * (np.diag(PQ.sum(axis=1)) @ self.Y - PQ @ self.Y)
same = np.sign(dY) == np.sign(self.iY)
self.gains = np.where(same, self.gains * 0.8, self.gains + 0.2)
self.gains = np.maximum(self.gains, MIN_GAIN)
self.iY = momentum * self.iY - self.eta * (self.gains * dY)
self.Y = self.Y + self.iY
self.Y -= self.Y.mean(axis=0)
self.it += 1- For the first 100 iterations the target probabilities are multiplied by 12 (early exaggeration): clusters form as tight balls that can move past each other before the layout freezes. Momentum is lower in that phase too.
- Each coordinate keeps its own step size (“gains”): it grows while the gradient keeps the same sign and shrinks by a fifth when the sign flips. This is Jacobs' delta-bar-delta rule from the original t-SNE code.
- A momentum step, then the picture is re-centred so it cannot drift.
- The step is a method rather than a loop because the browser calls it five iterations at a time and draws in between, which is the animation you see, and because new phrases warm-start from the current positions (section 07).
One choice makes the whole thing reproducible: the starting positions are not random but the first two principal components, scaled tiny (Kobak & Berens, 2019). The same room always gives the same map, in Python and in the browser, with no random seed to pass around.
What the map keeps and what it lies about
Trustworthiness, and a 10⁻¹² nudge
t-SNE is built to preserve neighbourhoods, and that is all it promises. It does not preserve distances between groups (two islands far apart on the map may be fairly close in the original space), the size of a group (dense crowds are spread out, sparse ones squeezed), or the global shape. The honest reading of the map is: “your neighbours on the sheet are probably your real neighbours; the rest of the picture is decoration.” The “Two phrases” mode on the project page exists to make that point: it shows the real cosine similarity of any two dots, next to how far apart the map drew them.
Because the promise is about neighbours, that is what gets measured. Trustworthiness (Venna & Kaski, 2001, as in scikit-learn) checks each point's 5 nearest map neighbours and charges a penalty for every one that was not a real neighbour, in proportion to how far down the real ranking it sits:
def trustworthiness(X_high: np.ndarray, Y_low: np.ndarray, k: int = 5) -> float:
n = X_high.shape[0]
if n <= k + 1:
return 1.0
Dh, Dl = pairwise_sq(X_high), pairwise_sq(Y_low)
nn_low = knn(Dl, k)
total = 0.0
for i in range(n):
order = sorted((float(Dh[i, j]), j) for j in range(n) if j != i)
rank = {j: r + 1 for r, (_, j) in enumerate(order)} # 1 = nearest in the original space
for j in nn_low[i]:
if rank[j] > k:
total += rank[j] - k
return 1.0 - 2.0 / (n * k * (2 * n - 3 * k - 1)) * total- For a map neighbour j that is not among i's k real neighbours, the penalty is its real rank minus k. A stranger from the far end of the room costs more than one from just outside the circle.
- Normalised so that 1 means no false neighbours at all. Checked against
sklearn.manifold.trustworthinessin the tests.
On the sample room the t-SNE map scores 0.86 against PCA's 0.78, and keeps 66% of each point's 5 real neighbours against PCA's 46%. The second figure below is the uncomfortable part. t-SNE's descent, with momentum, exaggeration and sign-switching gains, is chaotic: nudging the inputs by 10⁻¹² gives a map in which only 59% of each point's map neighbours are the same, while both maps are equally good (trustworthiness 0.86 and 0.90, KL 0.39 and 0.32). This is also why the browser's map and the Python's agree to 10 decimal places for ten iterations and then part ways: numpy adds numbers pairwise, JavaScript adds them one by one, and that is nudge enough. The parity test therefore checks the two layouts for equal quality, not equal coordinates.


How many groups? Ask the silhouette
k-means, k = 2…8, best mean silhouette wins
The groups are found by k-means in the 10-dimensional PCA space, not on the 2-D picture, so a group is a real cluster of similar phrases and not an artefact of where t-SNE happened to put things. k-means needs to be told how many groups to find, and nobody in the room knows. So the code tries every k from 2 to 8 and scores each answer with the mean silhouette: for each point, how close it is to the rest of its own group (a) compared with the nearest other group (b), as (b − a) / max(a, b). A point deep inside a tight group scores near 1, a point on the border near 0, a point that would be happier elsewhere below 0.
def choose_k(X: np.ndarray, kmin: int = 2, kmax: int = 8, seed: int = 1) -> tuple[int, np.ndarray, list[tuple[int, float]]]:
"""Try k = kmin..kmax (capped at n // 4), pick the best silhouette. Returns (k, labels, [(k, score)...]).
Rooms with fewer than 8 phrases are one group."""
n = X.shape[0]
kmax = min(kmax, n // 4)
if n < 8 or kmax < kmin:
return 1, np.zeros(n, dtype=int), []
best_k, best_labels, best_s, scores = 1, np.zeros(n, dtype=int), -math.inf, []
for k in range(kmin, kmax + 1):
labels, _ = kmeans(X, k, seed=seed)
if len(np.unique(labels)) < k:
continue
s = silhouette(X, labels)
scores.append((k, s))
if s > best_s + 1e-12:
best_k, best_labels, best_s = k, labels, s
return best_k, best_labels, scores- Rooms with fewer than 8 phrases are one group: there is nothing to split. The ceiling n/4 keeps groups from being pairs.
- Each k runs k-means three times from k-means++ starts and keeps the best; the k with the best silhouette wins, first one on ties.
The random starts come from a tiny seeded generator (mulberry32, 32-bit integer arithmetic) implemented identically in Python and JavaScript, so both pick the same starting centres and find the same groups: the parity test checks that the partitions, the silhouette curve and the names are identical.

Naming a group
The two most distinctive terms
A group of dots needs a name people can read from the back of the room. The name is the two terms that are most over-represented in the group compared with the room as a whole. For each term, pgroup is the share of the group's phrases that contain it and proom the share of all phrases; the score pgroup · log(pgroup / proom) is that term's contribution to the KL divergence between “phrases in this group” and “phrases in the room”. A word that every phrase in the room uses scores zero however common it is in the group; a word that only this group uses scores high. Function words are excluded. The terms are the phrases' words plus a #topic tag for each topic of the 32-entry table a word belongs to, which is how a group of unrelated-looking words (piano, guitar, vinyl, choir) gets called “music”.
for w in d:
counts[w] = counts.get(w, 0) + 1
scored = []
for w, cnt in counts.items():
pc = cnt / len(members)
pr = room[w] / n
scored.append((-(pc * math.log(pc / pr)), -cnt, term_kind(w), w))
scored.sort()
picked: list[str] = []
for _, _, _, w in scored:
w = w.lstrip("#")
if w not in picked:
picked.append(w)
if len(picked) == top:
break
names.append(" · ".join(picked))- The score, with its tie-breaks: more occurrences first, then topic tags before topical words before unknown words before everyday ones, then alphabetical. Deterministic in both languages.
- Take the top two distinct terms; a topic tag loses its
#. “music · choir”, “food · bake”, “games · board”.
The model runs on the phone
Transformers.js, and failing gracefully
The sentence model never runs on a server, because there is no server: the phone that typed the phrase embeds it. web/embed.js loads @huggingface/transformers 3.7.6 from jsDelivr, which runs the ONNX version of MiniLM in WebAssembly (or WebGPU where it exists); the 23 MB of weights download once from Hugging Face's CDN and stay in the browser cache. On a 2023 phone a phrase takes well under a second once the model is loaded. Loading starts the moment the join page opens, so by the time someone has typed their answer the model is usually ready.
function loadModel(opts) {
opts = opts || {};
if (pipe) return Promise.resolve(pipe);
if (failure) return Promise.reject(failure);
if (loading) return loading;
if (typeof window === "undefined" || typeof WebAssembly === "undefined") {
failure = new Error("no browser"); return Promise.reject(failure);
}
var timeoutMs = opts.timeoutMs || 90000, timer = null;
var timeout = new Promise(function (_, rej) { timer = setTimeout(function () { rej(new Error("model took too long to load")); }, timeoutMs); });
var work = dynamicImport(TRANSFORMERS_URL).then(function (m) {
m.env.allowLocalModels = false;
return m.pipeline("feature-extraction", MODEL, {
progress_callback: function (p) {
if (opts.onProgress && p && typeof p.progress === "number") opts.onProgress(p.progress / 100, p.file || "");
}
});
}).then(function (p) { pipe = p; return p; });
loading = Promise.race([work, timeout]).then(function (p) { clearTimeout(timer); return p; }, function (e) {
clearTimeout(timer); failure = e; loading = null; throw e;
});
return loading;- Three ways to fail early: no browser (the same file runs in node for the tests), no WebAssembly, or a load that takes longer than 90 seconds. A failure is remembered for the rest of the page load so the phone does not try again on every keystroke.
- The library is loaded with
import()wrapped innew Function, so a browser too old to parse dynamic imports fails here, cleanly, rather than refusing to run the whole script.
function embed(texts, opts) {
opts = opts || {};
var hash = function () { return { embedder: "hash1", vectors: texts.map(hashEmbed) }; };
if (opts.prefer === "hash1") return Promise.resolve(hash());
return loadModel(opts).then(function () { return modelEmbed(texts); })
.then(function (vectors) { return { embedder: "minilm", vectors: vectors }; })
.catch(function (e) { var r = hash(); r.fallbackReason = String(e && e.message || e); return r; });
}
- The contract every caller sees: a promise of
{embedder, vectors}. If anything goes wrong on the model path the fallback answers instead, taggedhash1, with the reason attached so the page can say why.
The sandbox this project was built in has no network, so the MiniLM path could not be exercised end to end; it is written defensively and the fallback path is what every test runs. The host can set a room to “fallback only” if a venue's Wi-Fi is bad. A quick check of the model path on a real device: open the Local room, choose the sentence model, load the sample, and the status line should say “Sentence model: 60 phrases embedded”.
New dots drop in live
Warm-starting the layout
A full t-SNE run from scratch for every new phrase would make the whole map jump every few seconds, which is exactly what a room of people watching their own dot does not want. Instead the worker keeps the current positions and warm-starts:
if (warm) {
Y = [];
for (i = 0; i < n; i++) Y.push(prev[items[i].id] ? Float64Array.from(prev[items[i].id]) : null);
for (i = 0; i < n; i++) if (!Y[i]) { // new point: beside its 3 nearest known neighbours
var nn = M.nearest(X, i, n - 1).filter(function (o) { return Y[o.j] && prev[items[o.j].id]; }).slice(0, 3);
var x = 0, y = 0;
if (nn.length) { nn.forEach(function (o) { x += Y[o.j][0] / nn.length; y += Y[o.j][1] / nn.length; }); }
var a = (i * 2.399963) % 6.283185; // deterministic small offset so twins do not overlap
Y[i] = Float64Array.of(x + 0.6 * Math.cos(a), y + 0.6 * Math.sin(a));
}
t = new M.TSNE(P, Y); t.it = M.EARLY_ITERS; // no early exaggeration on a warm start
postMessage({ type: "progress", job: job, it: 0, Y: Y.map(function (r) { return Array.from(r); }) });
for (i = 0; i < WARM_ITERS; i += PROGRESS_EVERY) { t.step(PROGRESS_EVERY); postMessage({ type: "progress", job: job, it: i + PROGRESS_EVERY, Y: t.Y.map(function (r) { return Array.from(r); }) }); }- A new phrase starts at the average position of its three most similar phrases (by cosine, in the embedding space), pushed out by a small deterministic offset so two identical answers do not land on top of each other.
- The iteration counter is set past the early-exaggeration phase, so the short run that follows is a gentle settling, not a re-shuffle. 80 iterations, posted five at a time, is what the animation shows.
- The neighbour probabilities P are recomputed for the whole room each time; with at most 500 phrases that is a few million operations, well under a second in a worker.
Everything in web/mapmath.js runs in a Web Worker so the page stays responsive, and each job carries a number: if a second phrase arrives while the first is still laying out, the worker's late messages for the old job are dropped.
A database anyone may read and nobody may wreck
Realtime, row-level security, and why the public key is safe
The shared state is two Postgres tables in the same Supabase project as the Run List and Scorepad: rooms (a four-letter code, the question, a host secret, open or closed) and room_entries (the phrase, its vector, which embedder made it, and a device token). The browser talks to Postgres directly with a publishable key that anyone can read out of the page source, so the key must not be what keeps the data safe. What keeps it safe is the rules in setup.sql: row-level security says what each role may read, and every write goes through a function that checks a secret the row's owner holds.
drop policy if exists "read rooms" on public.rooms;
create policy "read rooms" on public.rooms for select using (true);
drop policy if exists "read entries" on public.room_entries;
create policy "read entries" on public.room_entries for select using (true);
revoke all on public.rooms from anon, authenticated;
revoke all on public.room_entries from anon, authenticated;
grant select (code, prompt, open, embedder, created_at) on public.rooms to anon, authenticated;
grant select (id, room_code, text, embedding, embedder, created_at, updated_at) on public.room_entries to anon, authenticated;- Reading is open: anyone who knows a code can see its prompt and its phrases, which is the point of a room; phrases are anonymous and nothing else is stored.
- All table privileges are taken away and only named columns are granted back. The host secret and the device tokens are never readable by anyone, and there is no insert, update or delete privilege at all.
create or replace function public.add_entry(c text, token uuid, new_text text, vec real[] default null, emb text default 'hash1')
returns uuid
language plpgsql security definer set search_path = public
as $$
declare r rooms; new_id uuid; t text := left(trim(new_text), 140);
begin
select * into r from rooms where code = upper(c);
if r.code is null then raise exception 'No room with that code.'; end if;
if not r.open then raise exception 'This room is closed.'; end if;
if t = '' then raise exception 'Write something first.'; end if;
if emb not in ('minilm', 'hash1') then raise exception 'Unknown embedder.'; end if;
if (select count(*) from room_entries where room_code = r.code) >= 500 then raise exception 'This room is full (500 phrases).'; end if;
if (select count(*) from room_entries where room_code = r.code and device = token) >= 3 then
raise exception 'You already have 3 phrases in this room. Edit or delete one instead.';
end if;
if (select count(*) from room_entries where device = token and created_at > now() - interval '1 hour') >= 60 then
raise exception 'Slow down: too many phrases from this device.';
end if;
insert into room_entries (room_code, text, embedding, embedder, device) values (r.code, t, vec, emb, token) returning id into new_id;- A
security definerfunction runs with the rights of its creator, so it may insert where the caller may not. The function's body is the whole policy. - Closed rooms take nothing. A room is closed by its host with
set_room(code, secret, false), where the secret only the host's browser holds is the proof. - Rate limits as plain counts: 500 per room (the layout's ceiling), 3 per device per room, 60 per device per hour. The device token is a random UUID the phone makes once and keeps; it is also what lets a person edit or delete their own phrase later, and only theirs.
Live updates come from Supabase Realtime: the table is added to the supabase_realtime publication with full replica identity, and every open page subscribes to changes filtered by room code. Realtime applies the same read policy, so it can only ever tell a page what the page could have read anyway. A poll every 30 seconds covers the case where realtime was never switched on.
channel = sb.channel("room-" + code).on("postgres_changes", { event: "*", schema: "public", table: "room_entries", filter: "room_code=eq." + code }, function (p) {
if (p.eventType === "INSERT" || p.eventType === "UPDATE") {
var e = rowToEntry(p.new), i = entries.findIndex(function (x) { return x.id === e.id; });
if (i < 0) entries.push(e); else entries[i] = e;
} else if (p.eventType === "DELETE" && p.old && p.old.id) {
entries = entries.filter(function (x) { return x.id !== p.old.id; });
} else return reload();
emit();
}).subscribe();- One channel per room, all three event kinds, filtered server-side by
room_code. - Inserts and updates carry the full new row (vector included), deletes carry the old id. The local copy is patched and the map warm-starts; anything unexpected falls back to a full reload.
The rules are tested without a Supabase account: tests/sql.test.mjs runs setup.sql twice (it must be safe to re-run) in PGlite, a Postgres compiled to WebAssembly, and checks 37 things a stranger, a participant and a host can and cannot do.
The QR code, from scratch
Byte mode, level M, versions 1–10, 270 lines
The join link has to get from the screen into fifty phones, and the only reliable way is a QR code. Rather than ship a library, web/qr.js implements the standard (ISO/IEC 18004) for what this page needs: text up to 213 bytes, error-correction level M (a quarter of the symbol may be damaged), versions 1 to 10 (21 to 57 modules a side). A QR code is four things: the text as bits, Reed–Solomon error-correction bytes, a fixed layout of finder, timing and alignment patterns, and a mask chosen so the picture has no large blank areas or finder look-alikes.
function codewords(bytes, v) {
var buf = new BitBuffer(), cap = dataCapacity(v), limit = 8 * cap;
buf.put(4, 4); // byte mode
buf.put(bytes.length, v < 10 ? 8 : 16);
for (var i = 0; i < bytes.length; i++) buf.put(bytes[i], 8);
for (i = 0; i < Math.min(4, limit - buf.bits.length); i++) buf.bits.push(0); // terminator
while (buf.bits.length % 8) buf.bits.push(0);
for (i = 0; buf.bits.length < limit; i++) buf.put(i % 2 === 0 ? 0xEC : 0x11, 8); // pad bytes
var data = buf.bytes(), groups = BLOCKS[v - 1], gen = generatorPoly(EC_PER_BLOCK[v - 1]);
var dc = [], ec = [], off = 0, maxDc = 0;
for (var g = 0; g < groups.length; g += 2) {
for (var b = 0; b < groups[g]; b++) {
var len = groups[g + 1], block = data.slice(off, off + len); off += len;
dc.push(block); ec.push(rsRemainder(block, gen)); maxDc = Math.max(maxDc, len);
}
}
var out = [];
for (i = 0; i < maxDc; i++) for (b = 0; b < dc.length; b++) if (i < dc[b].length) out.push(dc[b][i]);
for (i = 0; i < gen.length - 1; i++) for (b = 0; b < ec.length; b++) out.push(ec[b][i]);
return out;
}
// ---------------------------------------------------------- the matrix --- The bit stream: a 4-bit mode (byte), the length, the bytes, up to four zero terminator bits, padding to a byte, then the alternating pad bytes
EC 11the standard prescribes until the version's data capacity is full. - Data is split into blocks (version 8 has two blocks of 38 bytes and two of 39), and each block gets its own Reed–Solomon remainder in GF(2⁸): dividing the data polynomial by a generator polynomial whose roots are the first n powers of α. A reader divides again and uses the non-zero remainder to locate and fix errors.
- Interleaving: byte 1 of every block, then byte 2 of every block, and so on, then the error-correction bytes the same way, so a smudge across the symbol damages a little of every block instead of all of one.
Eight mask patterns are tried and the one with the lowest penalty score wins. The scoring follows the standard's four rules, with one deliberate quirk: like the Python qrcode library, the score is computed before the format bits go in. That keeps the mask choice identical to the library's, which is what the test compares against; any mask gives a valid, readable code either way.
function penalty(m) {
var n = m.length, score = 0, r, c, i;
function runs(get) { // rule 1: runs of 5+ same-coloured modules
for (var a = 0; a < n; a++) {
var prev = get(a, 0), len = 0;
for (var b = 0; b < n; b++) {
var v = get(a, b);
if (v === prev) len++; else { if (len >= 5) score += len - 2; len = 1; prev = v; }
}
if (len >= 5) score += len - 2;
}
}
runs(function (a, b) { return m[a][b]; }); runs(function (a, b) { return m[b][a]; });
for (r = 0; r < n - 1; r++) for (c = 0; c < n - 1; c++) // rule 2: 2x2 blocks
if (m[r][c] === m[r][c + 1] && m[r][c] === m[r + 1][c] && m[r][c] === m[r + 1][c + 1]) score += 3;
function finderLike(get) { // rule 3: 1:1:3:1:1 pattern with 4 light modules on one side
for (var a = 0; a < n; a++) for (var b = 0; b + 11 <= n; b++) {
var p = []; for (i = 0; i < 11; i++) p.push(get(a, b + i));
var core = p[1] === 0 && p[4] === 1 && p[5] === 0 && p[6] === 1 && p[9] === 0;
if (!core) continue;- Rule 1: runs of five or more same-coloured modules in a row or column cost 3 plus one per extra module. Rule 2: every 2×2 block of one colour costs 3.
- Rule 3: the finder pattern's 1:1:3:1:1 stripe, with four light modules on either side, costs 40 wherever it appears, because a reader might mistake it for a corner.
tests/make_qr_truth.py asks the Python library for the module matrix of eleven texts (from “A” to 213 bytes, including UTF-8) in byte mode at level M, and tests/qr.test.js checks that this encoder produces the same version, the same mask and the same matrix for every one of them. All eleven match module for module. (Two bugs were found this way: the column walk skipping two columns past the timing column, and the mask scoring with format bits in place.)
Results
The sample room, 60 phrases, fallback embedder
Every number is from python -m roommap.figures on the built-in sample (60 answers to “What are you obsessed with right now?”), embedded with the fallback since the sandbox could not download the model. Trustworthiness and neighbours kept use k = 5 and are measured against the 10-dimensional PCA space the layout was computed from.
| group | phrases | who is in it |
|---|---|---|
| food · bake | 7 | sourdough, fermenting, ramen broth, croissants, Italian cookbook, cold brew, and the puppy that eats shoes |
| music · choir | 6 | jazz piano, guitar, vinyl, techno, choir, songs on a synthesizer |
| ai · models | 7 | fine-tuning, transformer papers, agents, reinforcement learning, diffusion, plus roasting coffee beans and learning Rust (“learning”) |
| games · board | 4 | chess puzzles, board games, speedrunning, Lego |
| writing · books | 5 | a compiler, Le Guin, a fantasy novel, a journal, poetry |
| endurance · cities | 7 | marathon, trail running, cycling, open-water swimming, a road trip, Japanese, urban planning |
| craft · garden | 14 | pottery, knitting, woodworking, tomatoes, houseplants, bees, and a loose tail of others |
| outdoors · screen | 10 | bouldering, climbing, camping, film noir, Ghibli, film photography, watercolours, birdwatching, foraging, ancient Rome |
The fallback is doing what it can: the groups that share topic words (food, music, games, AI) are clean, and the rest are loose because “Bouldering twice a week and my fingers hurt” and “Watching old film noir from the forties” share no word and no topic. That is the fallback's ceiling, not the method's; with MiniLM vectors the same pipeline groups by meaning.



Timings
| step, 60 phrases | Python | node (same code as the browser) |
|---|---|---|
| fallback embedding | 10 ms | 53 ms |
| PCA to 10-D | 71 ms | 123 ms |
| t-SNE, 500 iterations | 297 ms | 157 ms |
| k-means × 7 values of k + names | 45 ms | 25 ms |
Wall-clock on a shared 2-CPU sandbox, from python tests/parity.py. In the browser the whole layout of the 60-phrase room, including animation, reports 65–220 ms; a warm start for one new phrase is about 60 ms. t-SNE is O(n²) per iteration, so a full 500-phrase room is roughly 70 times slower, about 10 s in the worker, which is why warm starts matter. The QR encoder takes about a millisecond per code.
Browser against the Python
python tests/parity.py runs both pipelines on a 12-phrase list and the 60-phrase sample. Embeddings agree to 10⁻¹⁵, PCA distances to 10⁻¹³, the first three mulberry32 draws exactly, the groups, the silhouette curve (to 10⁻⁹) and every name exactly, and the first ten t-SNE iterations to 10⁻¹¹ relative. After that the two layouts diverge as described in section 03 and are compared on quality: KL 0.34 against 0.39, trustworthiness 0.92 against 0.86, neighbours kept 66% against 66%.
Limits
What to say when someone asks
- The model path is untested. It was written without network access; the fallback is what the tests and the screenshots exercise. First thing to check on a real device is the Local room with the sentence model selected.
- The fallback knows spelling, not meaning. Two phrases about the same thing in different words are strangers to it unless the 32-topic table catches both. A room on fallback only is a map of shared words.
- t-SNE is chaotic and local. Positions are not reproducible across machines beyond the first few iterations, distances between groups mean nothing, and a group's size on the map says nothing about its spread. Trustworthiness is the number to trust.
- Groups are k-means groups. k-means likes round groups of similar size and will cut a long thin cluster in two. The silhouette picks the least bad k, not a true one; a room with no structure still gets groups and names.
- Names are two words. They come from the phrases' own words and a hand-written topic table, so a group of synonyms with no shared word gets a name from whichever word is least shared with the room, which can be odd (“outdoors · screen”).
- Anyone with a code can read a room, and codes are four characters from an alphabet of 31. Phrases are anonymous and short-lived, which is the right fit for a live event and the wrong fit for anything private. Rooms are not deleted automatically; the host clears them.
- 500 phrases, n² maths. Above a few hundred the worker's layout takes seconds and the phones' local maps too; the database refuses the 501st phrase.
- The QR encoder stops at version 10 (213 bytes), far more than a join link needs, and does only byte mode, so numeric-only or alphanumeric text is encoded less compactly than a general library would.
Reproduce
Every figure and number
cd projects/room-map pip install -r requirements.txt # numpy, scipy, scikit-learn, matplotlib, pytest python -m pytest -q # 18 tests, about 3 s python -m roommap.figures # out/*.png and out/results.json, about 5 s python tests/parity.py # Python against web/*.js in node, about 2 s pip install qrcode && python tests/make_qr_truth.py && node tests/qr.test.js npm i --no-save @electric-sql/pglite && node tests/sql.test.mjs # the database rules, run from the repo root
The project page needs a static web server (python -m http.server from william-site/) because the layout runs in a Web Worker; opened from file:// it still works, on the main thread and without the animation. Hosting and joining rooms need setup.sql run once in the Supabase SQL Editor, as described in the README.
References
Where the methods come from
- van der Maaten & Hinton (2008). Visualizing data using t-SNE. Journal of Machine Learning Research.
- Kobak & Berens (2019). The art of using t-SNE for single-cell transcriptomics. Nature Communications. (PCA initialisation, and what t-SNE preserves.)
- Wattenberg, Viégas & Johnson (2016). How to use t-SNE effectively. Distill.
- Venna & Kaski (2001). Neighborhood preservation in nonlinear projection methods: an experimental study. ICANN. (Trustworthiness.)
- Rousseeuw (1987). Silhouettes: a graphical aid to the interpretation and validation of cluster analysis. Journal of Computational and Applied Mathematics.
- Arthur & Vassilvitskii (2007). k-means++: the advantages of careful seeding. SODA.
- Reimers & Gurevych (2019). Sentence-BERT: sentence embeddings using Siamese BERT-networks. EMNLP. (The family all-MiniLM-L6-v2 belongs to.)
- Weinberger et al. (2009). Feature hashing for large scale multitask learning. ICML. (The fallback's hashed words and trigrams.)
- ISO/IEC 18004:2015. QR Code bar code symbology specification. Reed & Solomon (1960), Polynomial codes over certain finite fields.
Read further
Where everything lives
- roommap/embed.py the fallback embedder (Python twin of web/embed.js)
- roommap/pca.py PCA via the Gram matrix
- roommap/tsne.py exact t-SNE with PCA initialisation, steppable
- roommap/cluster.py mulberry32, k-means++, silhouette, choosing k, naming
- roommap/metrics.py trustworthiness and neighbourhood preservation
- roommap/pipeline.py everything in one call; prints JSON
- roommap/figures.py the figures and numbers above
- tests/test_roommap.py 18 pytest checks, several against scikit-learn
- tests/parity.py Python against the browser maths (uses tests/parity.js)
- tests/qr.test.js the QR encoder against the Python qrcode library (tests/make_qr_truth.py, tests/qr_truth.json)
- tests/sql.test.mjs the database rules in PGlite
- setup.sql tables, policies, functions, realtime
- web/embed.js MiniLM in the browser, and the fallback
- web/vocab.js the word list and the 32-topic table both languages read
- web/mapmath.js PCA, t-SNE, k-means, silhouette, names, metrics in JavaScript
- web/worker.js the layout off the main thread, with warm starts
- web/map.js the canvas: dots, names, labels, find-me, two-phrase mode
- web/qr.js the QR encoder
- web/page.js host, join and local views, Supabase and realtime
- web/sample.js the 60-phrase sample room
- README.md