Downloads · 30 days
34
79% of all-time downloads
apartejs/aparte-titler
aparte-titler is a token classification model from apartejs. Use it when you need labels on individual words, such as names. The card lists the license as mit.
A title for a conversation, from its first message, in the browser — the weight of an icon, no API call.
Downloads · 30 days
34
79% of all-time downloads
All-time downloads
43
Public
Repo size
54.5 MB
Likes
0
Public
Click a slice to open those files.
.bin28.8 MB · 44%
From the Hugging Face model README
A title for a conversation, from its first message, in the browser — the weight of an icon, no API call.
aparte-titler picks 3 to 6 words of the user's first message and returns them, in order, as the conversation's title. This repository holds every model of the family: one file for 17 European languages (133 KB in int3), a 6-language file, and one file per language (41 KB), each in four precisions — plus ONNX graphs for every model. It runs in a few milliseconds on one CPU core (2–4 ms in Python, 6–7 ms in the pure-JS runtime, for a typical 300-character message), in the browser, in Node, in Python, or in an iPhone, Mac or Android app, with no framework and no dependency. On the benchmark it scores nearly twice the naive baseline (the first five words of the message), and on the six languages where the comparison can be made fairly, the 133 KB file reaches 94 % of its 2.4 GB teacher — a model 18,000× heavier. Because it only ever copies words from the message, it is immune to prompt injection by construction: an instruction hidden in a message can at worst produce a poor title, never be executed.

latin) and +0.016 (latin-mini). The 6-language file does not move: its languages were never short of data.In the browser or Node — the runtime is one dependency-free file (titler.js, 12 KB of source, 6 KB minified), published as @aparte/titler; @aparte/titler-latin bundles the runtime and the default model together, with no dependency of its own:
import { loadTitler } from "@aparte/titler-latin";
const titler = await loadTitler();
titler.title("Write me a cover letter for a junior data analyst position at a bank");
In a bundled browser app, serve the .bin yourself and pass it to loadTitler(url): a bundler does not rewrite the package-relative URL that the no-argument form uses.
or with any file of this repository:
import { Titler } from "@aparte/titler";
const bytes = await fetch("https://huggingface.co/apartejs/aparte-titler/resolve/main/v1.2/titler-v1.2-fr-int3.bin").then((r) => r.arrayBuffer());
new Titler(bytes).title("Peux-tu m'expliquer la photosynthèse chez les plantes ?");
On iPhone, iPad and Mac — the Swift package aparte-titler-swift (Swift Package Manager; iOS, macOS, watchOS, tvOS, visionOS, Linux) is the same runtime ported to Swift, and gives the same titles — checked on 1,496 messages in the four precisions. Its AparteTitlerLatin product bundles the default model:
import AparteTitlerLatin
let titler = try AparteTitlerLatin.load()
titler.title("Write me a cover letter for a junior data analyst position at a bank")
Any other file of this repository loads with Titler(contentsOf: url).
On Android — the Kotlin library aparte-titler-kotlin (Maven Central; Android 5.0 and later, any JVM 8 or later, usable from Java) is the same runtime ported to Kotlin, and gives the same titles — checked on the same 1,496 messages, on the JVM and on Android emulators. dev.apartejs:titler-latin bundles the default model:
implementation("dev.apartejs:titler-latin:1.2.0") // build.gradle.kts
val titler = TitlerLatin.load()
titler.title("Write me a cover letter for a junior data analyst position at a bank")
Any other file of this repository loads with Titler.load(stream), e.g. from your app's assets.
In Python — the reference implementation, numpy only, in the repository:
python reader.py v1.2/titler-v1.2-latin-int3.bin "Peux-tu m'expliquer la photosynthèse chez les plantes ?"
No framework is needed anywhere: the file format is documented and the reader is 250 lines. Give the model only the user's first message, never a system prompt or the conversation history.
One folder per version. v1.2/ is current; v1/ and v1.1/ stay for anyone who pinned them.
| model | languages | fp32 | int8 | int4 | int3 |
|---|---|---|---|---|---|
v1.2/titler-v1.2-latin (default) | en, fr, es, de, pt, it, nl, pl, sv, da, fi, cs, ro, no, hu, hr, lt | 614 KB | 220 KB | 150 KB | 133 KB |
v1.2/titler-v1.2-latin-mini | the same 17, smaller vocabulary | 466 KB | 163 KB | 109 KB | 96 KB |
v1.2/titler-v1.2-efigsp | en, fr, es, de, pt, it | 392 KB | 134 KB | 89 KB | 77 KB |
v1.2/titler-v1.2-<lang> (×17) | one language | 245 KB | 77 KB | 48 KB | 41 KB |
int3 is the recommended precision: 3 bits per weight with one fp16 scale per row. It costs 0.005 against fp32 on the 17-language file, and int4 costs nothing — see the limits below. Every file carries its identity in its header (name, version, model, languages, precision), readable as titler.header in JS; manifest.json lists every file of every version with its size, parameters and languages. Why 133 KB for 141k parameters? The 3-bit weights are 53 KB; the file adds one fp16 scale per matrix row (26 KB), the BPE merge table (12,030 pairs, 48 KB), the JSON header with the byte-to-id table and the file's identity (4 KB) and the fp16 bias and normalization vectors (2 KB).
Moved in 1.2. Until 1.1 the files sat flat — the 17-language file at the root, everything else in variants/. They are now in one folder per version: …/resolve/main/titler-v1.1-latin-int3.bin is now …/resolve/main/v1.1/titler-v1.1-latin-int3.bin. A link pinned to a commit (…/resolve/<commit>/…) still works.
ONNX. The same 20 models of every version are also in onnx/ as fp32 and int8 graphs (opset 17, dynamic length), one folder per version with its own tokenizers, and an example that reproduces the runtime's decoding — for people who already run ONNX Runtime. 5 to 10 times heavier than the .bin; the fp32 graph gives exactly the titles of the fp32 .bin.
Word-level F1 against the reference title, on gold-chat, int3 files as published, hybrid decoding. Lines marked impossible, off-language, pasted transcriptions and unannotated lines are excluded; so are 61 lines whose text appeared in the training data. The tables below are rewritten by a script from scores.json, never typed by hand.
| language | one model (41 KB) | efigsp (77 KB) | latin-mini (96 KB) | latin (133 KB) | no model |
|---|---|---|---|---|---|
| English | 0.620 | 0.608 | 0.609 | 0.629 | 0.313 |
| French | 0.622 | 0.604 | 0.602 | 0.630 | 0.321 |
| Spanish | 0.640 | 0.614 | 0.640 | 0.635 | 0.318 |
| German | 0.620 | 0.605 | 0.602 | 0.620 | 0.277 |
| Portuguese | 0.591 | 0.596 | 0.619 | 0.598 | 0.325 |
| Italian | 0.556 | 0.542 | 0.557 | 0.549 | 0.307 |
| Dutch | 0.513 | — | 0.540 | 0.547 | 0.292 |
| Polish | 0.566 | — | 0.560 | 0.572 | 0.388 |
| Swedish | 0.697 | — | 0.682 | 0.661 | 0.303 |
| Danish | 0.579 | — | 0.563 | 0.574 | 0.268 |
| Finnish | 0.546 | — | 0.545 | 0.557 | 0.364 |
| Czech | 0.573 | — | 0.552 | 0.585 | 0.315 |
| Romanian | 0.610 | — | 0.600 | 0.618 | 0.323 |
| Norwegian | 0.629 | — | 0.624 | 0.617 | 0.289 |
| Hungarian | 0.570 | — | 0.559 | 0.578 | 0.346 |
| Croatian | 0.606 | — | 0.597 | 0.611 | 0.340 |
| Lithuanian | 0.549 | — | 0.541 | 0.568 | 0.354 |
Which file to use. The 17-language file scores at least as well as the one-language file for 11 of the 17 languages, for a third of their combined size, so it is the default. The one-language file is better for Swedish (+0.036) and Norwegian (+0.012), and within a hundredth for Danish, Italian, Spanish and German.
All three versions read by the same runtime on the same set, so this compares weights only.
| scope | 1.0 | 1.1 | 1.2 | 1.2 − 1.1 |
|---|---|---|---|---|
| English | 0.6203 | 0.6186 | 0.6198 | +0.0012 |
| French | 0.6153 | 0.6280 | 0.6217 | -0.0063 |
| Spanish | 0.6242 | 0.6433 | 0.6397 | -0.0036 |
| German | 0.6043 | 0.6210 | 0.6200 | -0.0010 |
| Portuguese | 0.5767 | 0.5969 | 0.5908 | -0.0061 |
| Italian | 0.5591 | 0.5556 | 0.5565 | +0.0009 |
| Dutch | 0.5389 | 0.5212 | 0.5135 | -0.0077 |
| Polish | 0.5720 | 0.5664 | 0.5662 | -0.0002 |
| Swedish | 0.6393 | 0.6457 | 0.6965 | +0.0508 |
| Danish | 0.4860 | 0.4900 | 0.5787 | +0.0887 |
| Finnish | 0.4842 | 0.4831 | 0.5463 | +0.0632 |
| Czech | 0.5077 | 0.5104 | 0.5734 | +0.0630 |
| Romanian | 0.3819 | 0.5464 | 0.6104 | +0.0640 |
| Norwegian | 0.5027 | 0.5266 | 0.6293 | +0.1027 |
| Hungarian | 0.5105 | 0.5261 | 0.5698 | +0.0437 |
| Croatian | 0.3441 | 0.3913 | 0.6061 | +0.2148 |
| Lithuanian | 0.4700 | 0.4738 | 0.5490 | +0.0752 |
| efigsp | 0.6004 | 0.6007 | 0.5948 | -0.0059 |
| latin-mini | 0.5706 | 0.5720 | 0.5877 | +0.0157 |
| latin | 0.5750 | 0.5737 | 0.5970 | +0.0233 |
| mean | +0.0388 |
The gain is almost entirely in the languages that were short of data: Croatian, Norwegian, Danish, Lithuanian, Czech, Romanian, Finnish, Hungarian and Swedish. English, French, Spanish, German, Portuguese, Italian, Dutch and Polish already had 12,000 examples or more and move within a point, in both directions. Above roughly 12,000 examples a language gains nothing measurable from more.
A ten-word message has ten candidate words and the model keeps three to six; a two-hundred-word message has two hundred. Scores follow the length far more than the language. gold-chat is balanced across lengths — about sixty lines per band per language — so every band below is measured, which was not the case before: in the older set five languages had no message longer than twenty words.
| scope | 0-10 | 10-20 | 20-40 | 40-80 | 80+ |
|---|---|---|---|---|---|
| English | 0.823 | 0.759 | 0.602 | 0.467 | 0.440 |
| French | 0.837 | 0.728 | 0.612 | 0.461 | 0.454 |
| Spanish | 0.822 | 0.756 | 0.571 | 0.551 | 0.460 |
| German | 0.788 | 0.751 | 0.577 | 0.481 | 0.489 |
| Portuguese | 0.769 | — | 0.518 | 0.463 | 0.474 |
| Italian | — | 0.709 | 0.572 | 0.428 | 0.446 |
| Dutch | 0.644 | 0.596 | 0.517 | 0.462 | 0.325 |
| Polish | 0.774 | 0.658 | 0.548 | 0.459 | — |
| Swedish | 0.763 | 0.789 | 0.628 | 0.564 | 0.747 |
| Danish | 0.729 | 0.748 | 0.582 | 0.462 | 0.365 |
| Finnish | 0.790 | 0.624 | 0.431 | 0.451 | — |
| Czech | 0.737 | 0.703 | 0.558 | 0.464 | 0.375 |
| Romanian | 0.817 | 0.748 | 0.584 | 0.523 | 0.346 |
| Norwegian | 0.785 | 0.729 | 0.586 | 0.530 | 0.512 |
| Hungarian | 0.795 | 0.657 | 0.507 | 0.477 | 0.399 |
| Croatian | 0.842 | 0.719 | 0.541 | 0.489 | 0.430 |
| Lithuanian | 0.758 | 0.657 | 0.465 | 0.531 | 0.333 |
An em dash means fewer than 50 lines in that band.
Each scope is trained three times; the published file is the seed with the best score on the validation split, never on the benchmark. The spread is what a different random draw costs, so no difference smaller than it should be read as real.
| scope | seeds | mean | range |
|---|---|---|---|
| English | 3 | 0.6287 | 0.0055 |
| French | 3 | 0.6271 | 0.0018 |
| Spanish | 3 | 0.6328 | 0.0022 |
| German | 3 | 0.6270 | 0.0062 |
| Portuguese | 3 | 0.6098 | 0.0084 |
| Italian | 3 | 0.5744 | 0.0072 |
| Dutch | 3 | 0.5266 | 0.0239 |
| Polish | 3 | 0.5729 | 0.0056 |
| Swedish | 3 | 0.6949 | 0.0067 |
| Danish | 3 | 0.5841 | 0.0077 |
| Finnish | 3 | 0.5545 | 0.0086 |
| Czech | 3 | 0.5760 | 0.0069 |
| Romanian | 3 | 0.6080 | 0.0382 |
| Norwegian | 3 | 0.6298 | 0.0015 |
| Hungarian | 3 | 0.5805 | 0.0001 |
| Croatian | 3 | 0.6099 | 0.0118 |
| Lithuanian | 3 | 0.5630 | 0.0013 |
| efigsp | 3 | 0.6159 | 0.0036 |
| latin-mini | 3 | 0.6068 | 0.0037 |
| latin | 3 | 0.6081 | 0.0011 |
These are the fp32 weights before quantization, averaged over the three seeds, so they sit slightly above the published int3 files of the first table: quantization costs about 0.005, and the published seed is chosen on validation, not on this benchmark. Romanian (0.038) and Dutch (0.024) are the least stable; most scopes move by less than a hundredth.
node.js becomes node and js).int3 gives a slightly lower score than fp32 and different titles. On the 17-language file: −0.005 F1, and the same title on only 50 % of messages — the rest differ by one near-tied word. int8 is identical to fp32 on 99 % of messages; int4 scores as well as fp32.title(message, 6) gives the fixed six-best decoding of 1.0.About 9 µs per character on one CPU core in Python (Ryzen AI Max) and 22 µs in the pure-JS runtime under Node 24: 2–4 ms and 6–7 ms for a typical 300-character message. No GPU, no network, under 1 MB of memory.
Code and weights: MIT © 2026 Paul Richez. The model was distilled from titles written by gemma 4 e2b (Apache 2.0), which also translated part of the training data; Google claims no rights in its outputs (Gemma Terms of Use §3.3). The messages come from WildChat-4.8M and WildChat-1M (ODC-BY), OASST2 (Apache 2.0), Aya (Apache 2.0), Tagengo (Apache 2.0) and MFAQ (CC0); the licence of each row is in the dataset. The benchmark annotations are ours (CC BY 4.0).
@software{aparte_titler_2026,
author = {Richez, Paul},
title = {aparte-titler: extractive conversation titles in 17 languages, in the browser},
year = {2026},
version = {1.2.0},
url = {https://apartejs.dev/models/titler/}
}