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

  1. Typea phone turns the phrase into 384 numbers (or 1056 with the fallback)
  2. Sendthe numbers go into a shared table; every open map hears about it
  3. ReducePCA to 10 numbers, then t-SNE to a spot on a flat sheet
  4. Groupk-means, with k chosen by silhouette, and two words per group

The ideas

  1. 01A phrase as an arrow
  2. 02PCA first, then t-SNE
  3. 03What the map keeps and what it lies about
  4. 04How many groups? Ask the silhouette
  5. 05Naming a group
  6. 06The model runs on the phone
  7. 07New dots drop in live
  8. 08A database anyone may read and nobody may wreck
  9. 09The QR code, from scratch
  10. →Results
  11. →Limits
  12. →Reproduce
  13. →References
  14. →Read further
01

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:

roommap/embed.pylines 94–110
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)
  1. 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.
  2. 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.
  3. 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.
  4. 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.
Why two embedders, and never mixed

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.

02

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):

roommap/tsne.pylines 32–58
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)
  1. 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.
  2. 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:

roommap/tsne.pylines 98–114
    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
  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.
  2. 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.
  3. A momentum step, then the picture is re-centred so it cannot drift.
  4. 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.

03

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:

roommap/metrics.pylines 29–42
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
  1. 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.
  2. Normalised so that 1 means no false neighbours at all. Checked against sklearn.manifold.trustworthiness in 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.

The sample room laid out by PCA alone and by t-SNE
Left: the first two principal components. Right: PCA to 10 dimensions then t-SNE. Same colours (the k-means groups). t-SNE keeps two thirds of each point's real neighbours; PCA keeps under half.
Two t-SNE runs from inputs that differ by 1e-12
Two runs from inputs that differ by 10⁻¹². Each is a fine map; they are not the same map. Any reading that depends on exactly where a group landed is a reading of noise.
04

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.

roommap/cluster.pylines 114–130
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
  1. Rooms with fewer than 8 phrases are one group: there is nothing to split. The ceiling n/4 keeps groups from being pairs.
  2. 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.

Mean silhouette against k for the sample room
The sample room: the silhouette keeps rising to k = 8 (0.33), the ceiling, which says the fallback embedder's groups are loose. With MiniLM vectors the curve usually peaks earlier and higher.
05

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”.

roommap/cluster.pylines 170–185
            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))
  1. 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.
  2. Take the top two distinct terms; a topic tag loses its #. “music · choir”, “food · bake”, “games · board”.
06

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.

web/embed.jslines 112–133
  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;
  1. 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.
  2. The library is loaded with import() wrapped in new Function, so a browser too old to parse dynamic imports fails here, cleanly, rather than refusing to run the whole script.
web/embed.jslines 147–155
  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; });
  }
  1. The contract every caller sees: a promise of {embedder, vectors}. If anything goes wrong on the model path the fallback answers instead, tagged hash1, with the reason attached so the page can say why.
Untested here

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”.

07

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:

web/worker.jslines 32–44
    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); }) }); }
  1. 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.
  2. 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.
  3. 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.

08

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.

setup.sqllines 36–44
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;
  1. 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.
  2. 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.
setup.sqllines 98–116
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;
  1. A security definer function runs with the rights of its creator, so it may insert where the caller may not. The function's body is the whole policy.
  2. 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.
  3. 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.

web/page.jslines 253–261
    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();
  1. One channel per room, all three event kinds, filtered server-side by room_code.
  2. 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.

09

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.

web/qr.jslines 71–93
  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 --
  1. 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 11 the standard prescribes until the version's data capacity is full.
  2. 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.
  3. 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.

web/qr.jslines 181–200
  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;
  1. 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.
  2. 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.

0.86trustworthiness of the t-SNE map (PCA alone: 0.78)
66%of each point's 5 real neighbours kept (PCA alone: 46%)
8groups, silhouette 0.33 (k = 2: 0.19)
70%of a point's 5 nearest neighbours are in its group
groupphraseswho is in it
food · bake7sourdough, fermenting, ramen broth, croissants, Italian cookbook, cold brew, and the puppy that eats shoes
music · choir6jazz piano, guitar, vinyl, techno, choir, songs on a synthesizer
ai · models7fine-tuning, transformer papers, agents, reinforcement learning, diffusion, plus roasting coffee beans and learning Rust (“learning”)
games · board4chess puzzles, board games, speedrunning, Lego
writing · books5a compiler, Le Guin, a fantasy novel, a journal, poetry
endurance · cities7marathon, trail running, cycling, open-water swimming, a road trip, Japanese, urban planning
craft · garden14pottery, knitting, woodworking, tomatoes, houseplants, bees, and a loose tail of others
outdoors · screen10bouldering, 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.

The sample room's map with group names
The sample room as the Python lays it out. The browser produces a map of the same quality with the same groups and names, though not the same coordinates (section 03).
Histogram of cosine similarities within and across groups
Cosine similarity of the 1,770 pairs of phrases. Pairs in the same group have median 0.05 (mean 0.15), pairs in different groups 0.016 (mean 0.027): with the fallback, most pairs are simply unrelated, and the groups are built on the few that are not.
KL divergence and trustworthiness over t-SNE iterations
Left: the objective during the 500 iterations; the drop at 100 is the end of early exaggeration. Right: trustworthiness overtakes the PCA-only layout within the first 50 iterations and settles near 0.86.

Timings

step, 60 phrasesPythonnode (same code as the browser)
fallback embedding10 ms53 ms
PCA to 10-D71 ms123 ms
t-SNE, 500 iterations297 ms157 ms
k-means × 7 values of k + names45 ms25 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