The problem in brief
A friend thinks of a Swedish word from a fixed list of 10 001 words and gives you a starting word. After each of your guesses he answers with two words: whether your guess is "hotter" or "colder" (semantically closer to or farther from his word) than your previous guess, and than your best guess so far. You keep guessing until you hit the word. Your program plays rounds interactively (up to 1000 rounds in the last test group) within 8 seconds of CPU time. No training data is provided: you may bring any word vectors you like from the internet, but the program runs offline on Kattis, so whatever knowledge you use must fit inside the source file.
Important caveat for this correction. The judge's similarity model is secret ("he reads news and books"). We cannot reproduce it, so every score below is measured against a simulated judge that we built ourselves: cosine similarity between fastText vectors trained on Swedish Common Crawl. Our own program deliberately uses different vectors (fastText trained on Swedish Wikipedia), so that it faces a realistic model mismatch. Real Kattis scores may be higher or lower.
How you are scored
If a round needs guesses, it earns
The exponent 13 makes this a harsh curve: 10 guesses give 99.9 points, 100 guesses 87.7, 500 guesses 51.3, 1000 guesses 25.4 and 3000 guesses less than 1. A test case scores the average over its rounds, and the three groups (, , ) are worth 30, 40 and 30 points.
There is no published threshold. The provided baseline guesses the vocabulary in alphabetical order, so is uniform on and its expected score is
First look at the data
The only attachment is vocabulary.txt: 10 001 lower-case Swedish words, mostly frequent news words (regeringen, miljoner), first names and place names, plus a surprising layer of EU-legislation jargon (associeringsavtal, budgetförordningen) and even a fungus class (agaricomycetes). This tells us the judge's corpus mixes news, books, Wikipedia and EU documents, and that some words will be missing from any vector set we pick: from the first 300 MB of the Wikipedia fastText file, 533 of the 10 001 words had no vector.
Two constraints shape everything:
- Size. Kattis accepts at most 128 KiB of source code. The vocabulary alone takes 31 KB once compressed with lzma and encoded in base85. That leaves room for about 6 to 7 bytes per word, while a fastText vector is 300 floats (1200 bytes). We must compress by a factor of 200.
- Time. With 1000 rounds in 8 s, a round may use about 7 ms of CPU, input and output included.
A first baseline
Alphabetical order scores 7.1 on average. A natural first idea is hill climbing: guess a neighbour of the best word so far and move whenever the answer is "hotter". It throws away most of the information: every answer is a statement about the hidden word, and all of them should be combined.
The key idea
Write for the (unit) vector of the hidden word, and , for the guess and the word it is compared to. The answer " is hotter than " means
Every answer is a half-space: a hyperplane through the origin, perpendicular to , cuts the space of words in two, and the target lives on the announced side. Each guess gives two such cuts (versus the previous guess and versus the best guess). If our vectors were exactly the judge's, each cut would remove about half of the remaining candidates and we would need about guesses after the first ones: in simulation, with the judge's own vectors, the median is 13 guesses and the score 98.
But our vectors are not the judge's, so some cuts will be "wrong" for the true target. Hard elimination would then throw the target away for ever. Instead we keep a soft score, the negative log-likelihood of each candidate under a noisy-answer model:
with for "hotter", for "colder", the logistic function and . A candidate barely on the wrong side of a cut pays little, one far on the wrong side pays a lot. We always guess the unguessed word with the smallest penalty (the most likely target). This is exactly logistic-regression-style Bayesian updating, and it is what makes the method robust to a mismatched model: on 150 simulated rounds the soft score beat hard "count the violated cuts" (92.9 vs 88.7 with full vectors).
The full solution
solution.py builds the submission; the generated program is about 118 KB.
- Vectors. fastText Swedish Wikipedia vectors filtered to the vocabulary (lower-case match, falling back to a capitalised form for names). A word without a vector gets the mean vector of the known words sharing its longest prefix of at least 4 letters, since Swedish inflections share their stem (andades borrows from andas): this rescued 375 of the 533 missing words.
- Compression: PCA + product quantisation. We centre the vectors, keep 48 PCA directions, cut each 48-d vector into 6 blocks of 8 numbers and replace each block by the nearest of 256 centroids learned by k-means on that block. A word is then 6 bytes (6 centroid ids), plus a small int8 codebook ( numbers). Plain scalar quantisation of fewer PCA dimensions at the same size was clearly worse (figure below): for a fixed budget, product quantisation keeps more dimensions, and dimensions matter more than precision.
- Fast updates by branch and bound. Updating the penalties of all 10 001 words after every answer is too slow for 7 ms per round. Since penalties only ever increase, we keep an active set of the 300 best candidates and remember , the smallest penalty outside it. Only active words are updated after each answer; when the best active penalty exceeds , the outside words are brought up to date in one matrix product and the active set is rebuilt. The guess is still the exact argmin, at a fraction of the cost.
- CPU budget. Each round gets a fair share of the remaining CPU time; past it, the program freezes the ranking and walks down it without further updates.
The core update, for each answer:
dv = X[g] - X[other] # cos(t,g) > cos(t,other) <=> t . dv > 0
new.append(dv if h == "hotter" else -dv)
pen[A] += np.logaddexp(0, -BETA * (X[A] @ np.array(new).T)).sum(1) # active set only

Results
All numbers come from our simulated judge (Common Crawl vectors), never from Kattis. Rows 2 to 6 use 150 rounds whose target has a vector in both models and no time limit; the last rows are the generated program in a real interactive loop, on targets drawn from the whole vocabulary.
| approach | median guesses | mean score per round |
|---|---|---|
| baseline: alphabetical order | 5000 | 7.1 (exact expectation) |
| upper bound: our model equals the judge's | 13 | 98.3 |
| Wikipedia vectors, full 300-d, soft penalty | 46 | 92.9 |
| same, hard count of violated cuts | 61 | 88.7 |
| same, hinge penalty | 58 | 89.6 |
| PCA 16-d x 4 bits (8 bytes per word) | 126 | 77.5 |
| PQ 48-d, 6 bytes per word (chosen) | 82 | 82.6 |
| failed idea: hard pruning of unlikely candidates (for speed) | 934 | 38.8 |
| final program, group 1 setting (, 5 runs) | 82 to 158 | 77.5 |
| final program, group 2 setting () | 98 | 74.3 |
| final program, group 3 time budget (CPU share of , 200 rounds) | 2511 | 20.7 |

Weighted over the three groups (30/40/30), this is about points out of 100 against our simulated judge. Two honest warnings. First, the long rounds come mostly from words our vectors know badly (or not at all), and they also eat the CPU budget: with even the output of thousands of guesses is costly in Python, so group 3 may run into the time limit on Kattis. Second, the real judge's vectors may be closer to ours (better scores) or farther (worse); the method itself does not depend on which.
The pruning row is a lesson in itself: throwing away candidates that look unlikely made each step faster but regularly threw away the target, because our model disagrees with the judge's. The branch-and-bound active set gives the same speed-up without changing a single decision.
What to remember
- Turn feedback into geometry. "Closer to than to " is the half-space ; any comparison answer can be read this way.
- Use soft, probabilistic evidence when your model is imperfect. Summing log-likelihoods (a logistic noise model) degrades gracefully; hard elimination fails as soon as one cut is wrong.
- When the model must ship inside the program, compression is part of the solution. PCA plus product quantisation keeps 82.6 of the 92.9 points at 1/200 of the size.
- Exact speed-ups beat approximate ones. Penalties only grow, so a lower bound lets you update a small active set and still pick the true best candidate.
- If you cannot see the judge, build one and say so. A simulated oracle with a different model than yours gives an honest estimate of the gap between "our model" and "their model".