NOTES — 05 · WHO WROTE THIS

The tool worked. The number didn't.

Upload a group chat export, paste a message, and the page names who wrote it from writing habits alone. The classifier has been fine for months. What kept being wrong — three separate times, each caught only by measurement — was my belief about how well it does that.

ENGLISH ONLY · ~16 MIN · 137 REAL ROUNDS DISCARDED · NOTHING IS EVER STORED

01What it does, and what it refuses to keep

Attach an export from a group chat, paste something one of those people wrote, and the page tells you who. It reads the whole log, builds a model per person, scores your paste against each, and shows how sure it is.

Everything happens in the tab. The file is not uploaded, the pasted message is not sent anywhere, and the verdict is not stored. That is not a privacy paragraph bolted on afterwards — it constrains what I am able to learn about my own tool, and section 08 contains a question I cannot answer because of it. I still think the trade is right.

02The scorer

Naive Bayes over character 2- and 3-grams, additively smoothed:

P(g | u) = ( countu(g) + α ) / ( totalu + α·V ) α = 0.1 score(u) = − Σg countq(g) · log P(g | u) / |qgrams|

Character n-grams rather than words because the signal in Korean group chat is in the texture — sentence endings, spacing habits, how many times someone types か, whether they put a space before a question mark. Word tokenisation throws most of that away.

It replaced a compression-based hybrid, and the replacement is the one conclusion in this write-up that has never been retracted. Over 100 real rounds in a five-person room:

True author (corpus)RoundsNaive BayesOld hybridNB+IDFIDF alone
Speaker 1 (3.51M chars)2785%89%89%86%
Speaker 2 (1.48M)2186%43%76%38%
Speaker 3 (1.28M)12100%75%75%
Speaker 4 (0.43M)1989%26%79%37%
Speaker 5 (0.45M)2167%5%29%24%
All10084%48%70%43%
The compression hybrid does not degrade gracefully — it collapses. One hit in twenty-one rounds against Speaker 5. Paired comparisons on the two speakers who had never been tested before: the hybrid loses 26–1 (p < 0.001).

Hold on to that 84%. Sections 04 and 07 take it apart.

A mitigation that died twice

When the hybrid first collapsed, it collapsed on a speaker whose messages were split across three accounts, so there was a comfortable reading available: it was not failing, it was confusing one person with themselves. Then two new speakers with one account each scored 26% and 5%, which killed that reading. Later I discovered I had also swapped two speakers' name labels in my notes, which killed it a second time — the original collapse was not even the account-split speaker. The numbers were all computed by corpus size and were never wrong. Only the labels were, and they were wrong for weeks.

03The ceiling is the room, not 100%

Accuracy has no meaning here without the number of suspects: in a two-person room, 50% is a coin. So the page carries a measured grid of what to expect, and refuses to show a number when the number would be a lie.

Two-person room — chance is 50%20-char query50100300
2,000-char corpus50%55%55%70%
10,00056%68%71%75%
50,00062%72%75%89%
200,00076%82%87%94%
350 paired rounds per cell. The first row is the whole point: 55% against a 50% baseline is not a result, it is noise wearing a percentage sign.

So the tool applies a rule rather than a special case for small rooms: if the expected edge over chance is under 10 points, it shows a warning instead of a figure. Twelve of sixty-four measured cells trip it, across every room size — a seven-person room with a 20-character paste is just as meaningless as a two-person room, and the old version would have printed 19% and let you believe it meant something.

Two details that cost me time and are worth stating:

  • The baseline is computed from the suspects actually on screen, not from the grid key. Snapping a five-person room to the "4" row of the grid must not change the chance level the user is being compared against.
  • One cell was a phantom. A single seed put the two-person / 2,000-char / 20-char cell at 39%, below the 50% coin — which, if real, would have been a genuinely interesting finding. A second seed gave 58%. Small rooms have enormous room-to-room variance; the published figure is now a weighted average over 350 rounds from two seeds. I nearly shipped a discovery that did not exist.
AND THE PANEL WAS SHOWING STALE NUMBERS

Ticking a suspect on or off did not recompute the expectation — the handler was bound to the query box only. Reduce a room from four people to two and the four-person figure stayed on screen. It was simply displaying a wrong number, confidently.

I found it by building a two-person room for a screenshot and switching back. A number being on screen does not mean it was computed under the conditions currently on screen; every path that changes the conditions needs checking.

04The leak

Here is how the tool is actually used. Export the whole conversation. Then scroll up, find a message, and paste it. The query is inside the corpus. The model is being asked to identify a sentence it has already memorised.

I dismissed this early on. My reasoning was mass: one 50-character message inside a 3.5-million-character corpus is 0.001% of it, far too small to matter. The arithmetic is right and the reasoning is wrong, and the paired measurement says so:

CorpusQueryIn the recordHeld outInflation
2,000 charsone sentence, 20–120 chars100%30%+71
50,00097%43%+54
200,00092%52%+40
1,000,00020–60 chars80%47%+33
1,000,000five short fragments, 8–25 chars each100%79%+21
Identical room, identical query, identical corpus size; only the true author's corpus swaps between including and excluding the pasted message.

The mechanism is coverage, not mass

Look at the scoring formula again. Every n-gram of the query contributes −log P(g|u), and an n-gram the model has never seen contributes −log(α/den) — a large penalty, because α is 0.1. If the sentence is in the corpus, the number of unseen n-grams is exactly zero, and the true author is the only person in the room paying no penalty at all. It is not about how much text you added. It is about whether anything is missing.

I re-measured this while writing this page, on a completely different Korean corpus — not the game's data, just to isolate the mechanism:

Corpus sizeUnseen n-grams, query in corpusUnseen, query held outScore gap
2,000 chars0.0%47.7%1.332
5,0000.0%31.0%0.943
10,0000.0%18.1%0.638
20,0000.0%7.3%0.386
300 trials per row, α = 0.1, vocabulary held fixed. Score gap in nats per n-gram. Zero, every time, in the left column — that is the whole effect.

And this is the answer to my own mass argument. Between the first and last rows the query's share of the corpus falls tenfold, while the advantage it confers falls only 3.4-fold. The effect is not proportional to mass, which is why making the corpus bigger dilutes it far more slowly than intuition promises.

The second thing I predicted wrong, in the opposite direction

I then guessed that pasting several short fragments would be harmless, since each is too small to matter. It is the worst case. If all five fragments are in the record, each one collects the full coverage advantage and they accumulate — 100% accuracy even against a million-character corpus, worse than a single long sentence. That bundle of four to seven short messages is exactly the shape of a real paste.

05Removing it: from the owner, not from everyone

My first fix was to subtract the query's n-grams from every model in the room. That is wrong, and measurably so: it takes legitimate counts away from the innocent, which leaves the true author relatively better off. It barely moved — 84% against a 31% baseline.

The correct fix is available for free, and the file hands it over: if the pasted text matches a message in the export, the export says whose message it is. No inference required. Subtract only from that person.

CorpusLeakedSubtractedModel rebuilt without itAgreement
2,000 chars100%29%29%100%
50,00097%44%44%100%
200,00089%43%43%100%
480 rounds. Subtracting the counts and rebuilding the model from scratch chose the same person every single time — not approximately, identically. Which matters, because subtraction costs time proportional to the query length while rebuilding costs time proportional to the corpus.

Two honest residuals. Messages are joined with newlines when the model is built, so a handful of n-grams straddling a line boundary survive subtraction; they never changed a ranking in 480 rounds, but they are there. And I suspected the owner-lookup would over-remove, since short phrases are said by several people — measured, that is 3 cases in 500 (0.6%), and zero out of 120 for messages in the 20–60 character band. The suspicion was unfounded.

What does remain is a gap I do not want to paper over: held-out scoring lands at 83% where building the corpus without the message from the start gives 79%. Four to five points of optimism survive the fix, and every number measured with the new version should be read with that discount applied.

06The bench was not running the fix

I built the leak removal, shipped it, and left a note to confirm the local test bench was using it. It was not. The scoring path still read the untouched models, which means all 107 rounds of real-world testing I had accumulated were scored with the leak in place. They are discarded.

Fixing the bench surfaced two traps that would each have produced a quietly wrong comparison:

  • Applying it to Naive Bayes only would have rigged the contest. The old hybrid has a compression axis, and a compressor benefits from a memorised string more than a Bayesian model does. Removing the leak from NB alone would have handicapped exactly the scorer I was arguing for. It goes on all five axes, and the cached preparation is rebuilt from the held-out models.
  • Subtracting only n-grams breaks the other axes. Two of the five read the joined raw text, not the gram counts, so removing from one and not the other means the axes are looking at different corpora — which is the one thing a comparison harness may never do.

The log now records how many messages were held out. A count and nothing else — never the text, never a name.

And then I lost seven more rounds because the browser had an older copy of the bench open and I only noticed when the new field failed to appear. The file is now versioned in its own name with a version stamp printed under the title, and a test asserts the stamp is there. A tool that can silently be the wrong version is a tool that will be.

07The collapse that was not the scorer

With the fixed bench, thirty fresh rounds. Five-person room, chance 20%:

ScorerAccuracy
Naive Bayes (shipped)8/30 = 27%
Old hybrid23/30 = 77%
NB + IDF11/30 = 37%
IDF alone11/30 = 37%
The shipped scorer, barely above chance, losing to the thing it replaced. And of its 22 misses, 21 named the same person — while reporting 73–85% confidence.

Confidently wrong, in the same direction, every time. That pattern is not incompetence, it is a systematic input error, but I did not think that first. I hypothesised that additive smoothing was biased by corpus size: the unseen-gram penalty depends on the corpus total, so a smaller corpus is penalised less, and this room ranged from 3.5M to 0.43M characters.

I built a bench with matched corpus imbalance and 100 rounds. No size bias — 80% accuracy with an even spread of choices, and three alternative smoothing schemes all within five points. The hypothesis died. Worse, the log had already contradicted it: the person NB kept choosing was not the smallest corpus in the room. Someone smaller was never chosen once. I could have known before writing the bench by reading my own log more carefully.

The answer was not in my data at all. I asked what had actually been pasted:

[8/4/26 9:37 PM] Sanghyun: ha.. [8/4/26 9:37 PM] Sanghyun: spending money on films is against my principles

8/4/26 is a US-locale short date. The parser knew Korean 2025-12-05 and European 05.12.2025 and did not recognise this, so header extraction failed and the raw text went into the scorer — with the sender's name in it four to six times. The user's devices have different language settings, so the copy format changed depending on which phone the message came from.

WHY A LEAKED NAME IS WORSE THAN NOISE

People almost never write their own name. Other people write it. So a name inside the query is not noise — it is evidence pointing away from the true author, a reversed signal. That is how a scorer that is normally 84% ends up at 27%, barely above the 20% coin. The person it kept naming is most likely whoever says "Sanghyun" most often in that room.

The fix widened the date pattern instead of chasing locales one at a time, and handled a second format — the English KakaoTalk copy, which has no separator at all between the name and the message. There is no string rule that splits 4:57 PM Jb come to the coffee shop correctly, because Jb is a name and so is Kyung Min. So the extractor is handed the actual participant list read from the uploaded file and strips the longest match. It is a lookup, not a guess.

Guards, because a net this wide can swallow ordinary text: the format only applies when at least two lines start with a time and they are a majority; the bracket formats always win; and times inside a message are preserved, so a sentence about arriving at 5:36 pm survives intact.

Most importantly, the page now says something when it cannot read the format. Two red warnings appear in the query meter — one for text that looks like a header but parsed as nothing, one for a stripped time with a name that matched nobody in the room. There will be another locale I have not seen. Next time the screen has to speak. This time it said nothing at all, and thirty rounds of measurement went in the bin.

08One speaker really is harder, and I said otherwise first

One of the five sits at 67% where the others are 85–100%. At nineteen rounds I reported that the confidence intervals overlapped and there was therefore no evidence of a hole. That is a bad test, and overlapping intervals do not mean what I used them to mean. Testing it directly:

  • 53/60 for the established speakers against 14/21 — Fisher exact p = 0.041
  • Matched on query length, 23/25 against 9/15 — p = 0.036

And the obvious explanation is not available. This speaker has 450,000 characters; another has 430,000 and scores 89%. Same corpus size, twenty-two points apart. What is left is that some people's writing simply has less fingerprint — they speak in the phrasing the whole room shares. That is not a data-volume problem, and more logs will not fix it.

The fair reading is not that this scorer is weak here. Every other scorer gets 5–29% on the same speaker while Naive Bayes holds 67%, more than three times chance. It is a hard speaker for everyone, and this is the only method that survives them. The prescription is to accumulate evidence across several independent messages rather than to trust one.

And here is the cost of the privacy decision from section 01: I cannot investigate why. The log keeps numbers, never text, so there is no way to go back and look at what those messages had in common. Answering the question needs a fresh measurement designed for it. I knew that when I chose the design, and I would choose it again, but it is a real price and it should be written down rather than glossed.

09Everything I got wrong

Kept as a list, in order, because the pattern in it is more useful than any single entry.

#The mistakeHow it died
1A parser misread one export as JSON; the page's own self-test had been dead on line onemeasurement
2Nearly reported a single-seed 39% cell as a real signalmeasurement
3An experiment shuffled before slicing, so it measured nothing at allmeasurement
4"The intervals overlap, so there is no hole"measurement
5Blamed that speaker's weakness on corpus sizemeasurement
6"The leak is negligible — the mass is tiny"reasoning
7Fixed the leak by subtracting from everyonemeasurement
8Assumed the bench was running the fixmeasurement
9Suspected the owner lookup of over-removingmeasurement
10"Several short fragments will be harmless"reasoning
11Swapped two speakers' labels for weeksa screenshot
12The corpus-size-bias hypothesismeasurement
13Shipped a parser with Korean and European dates and no US onesasking the user
Every one has the same shape: the thing was running, and the thing it was measuring was not the thing I thought.

Two patterns worth extracting.

Not one of these was caught by thinking about it. Eleven fell to a measurement, one to looking at a screenshot, one to asking a question. And the two I reached by pure reasoning — numbers 6 and 10, both of the form "this is too small to matter" — were both wrong, and one of them was wrong in the opposite direction from my prediction. I no longer trust myself to conclude that something is negligible without measuring it.

The last one is a different species. The bench could reject my hypothesis but could never produce the answer, because the answer — what the user had actually pasted — was not in my possession. I ran an experiment where I should have asked a question. When the missing information is not in your hands, measuring harder just gets you a more confident wrong answer.

10So what is the accuracy

I do not know yet, and that is the honest end of this.

The 84% figure came from 107 rounds scored with the leak in place; those are discarded. The next 30 were scored through a broken parser; those are discarded too. In the user's own recent sessions, 26 of 41 pasted messages (63%) were present in the uploaded record, so the leak was partial rather than total, which puts the honest estimate at 70–75% — and even that should be read as four to five points optimistic, per section 05.

The claim I retracted along with the number was more interesting than the number itself. I had concluded that real friend-group chat is easier to attribute than a crowdsourced dialogue benchmark, based on 83–88% against 43–52%. Two errors leaned the same way: the conditions were not matched (different corpus sizes, different query shapes) and the real-world side was leaking. Matched properly, with the leak removed, the benchmark scores 79% and the real room 70–75%. The conclusion was not merely unsupported, it pointed the wrong way.

What survives is the comparison between scorers. All five were run on the same rounds under the same conditions carrying the same leak, so the ranking is untouched even though every absolute figure moved. Relative claims survived what absolute claims did not, which is worth remembering next time I am tempted to publish a single headline number.

Roughly thirty clean rounds are needed before the page can state a figure again. Until then it states none.

Try it on your own group chat. The file never leaves the tab, and neither does the answer.

Open Who Wrote This →