跳到正文
Hacker News-首页· speckx·· 3 小时前AI 评分63

开发者逆向分析 Apple 系统词典,揭示 macOS 键盘敏感词与分级机制

I dumped Apple’s word list

AI 导读

作者 speckx 通过逆向分析 macOS 的 Lexicon.framework 及内置词库,揭示了 Apple 键盘对词语的隐藏分级与分类机制。研究发现系统词典将词语分为粗俗、进攻性和敏感三个层级,并带有涵盖政治、宗教等主题的标志;其中英文敏感词表中仅包含了特朗普等特定政治人物,而多国词典在具体词汇的分级上存在显著差异。

正文 · 原文

I Dumped Apple's Fucking Word List

November 3, 2026

TL;DR

  • ~/Library/Keyboard/VulgarWordUsage.db is real. It records which swear words you use, per app and per recipient, so autocorrect learns to stop "ducking" you. On my Mac it is empty, and typing swear words into a native app did not fill it.
  • The interesting part is upstream. Apple's system dictionary flags every word with one of three tiers (vulgar, offensive, sensitive) and up to eleven topic flags (sex, drugs, religion, politics, violence, …).
  • There is no public API for any of it. I read the flags with a few private C functions from Lexicon.framework. Code below, in C and in Swift. The full dumps for 15 languages are in this gist (uncensored, so mind the content warning).
  • Highlights: Oedipus is vulgar. Tom and Mick are offensive. pee, poo, niggle and underling are sensitive. And "Trump" is the only living politician in the English sensitive tier, next to Hitler, Mao, Osama bin Laden and the Taliban. Biden, Obama and Putin are clean.

Content note: this article prints the words uncensored, slurs included, because the whole point is to see what Apple flags. The one exception is the n-word: it is in the list in English and several other languages, and I'm not writing it out.

It started with a tweet

Tweet by Nick (@nicksdot): 'TIL: macOS has a database to track our use of profanity', showing a Finder listing with user_model_database.sqlite-wal and VulgarWordUsage.db. Helge replies 'Where is it located?' and Nick answers '~/Library/Keyboard/VulgarWordUsage.db'.

Nick posted this, I asked where it lives, and he answered. A database that tracks my profanity is not something I can leave alone, so I opened mine.

The database

$ sqlite3 -readonly ~/Library/Keyboard/VulgarWordUsage.db .schema
CREATE TABLE properties (ROWID INTEGER PRIMARY KEY, key, value, UNIQUE (key));
CREATE TABLE IF NOT EXISTS "vword_usage" (
    ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
    app                          TEXT, -- app identifier
    recipient                    TEXT, -- recipient identifier
    vword                        TEXT, -- vulgar word
    word_reading                 TEXT, -- the reading form of a word (relevant for Japanese)
    usage_count                  INTEGER DEFAULT 0,
    last_use_timestamp           REAL DEFAULT 0, -- last use timestamp (seconds since the reference date)
    journaled
);
CREATE UNIQUE INDEX app_recipient_vword_word_reading_idx ON vword_usage(app, recipient, vword, word_reading);

So the keyboard can know that you say fuck in Messages to one particular friend, but not in Mail to your boss. The unique index is on (app, recipient, word, reading), so that is the granularity.

Mine has zero rows. The properties table holds version = 2 and nothing else. The file was last written in April 2024.

What it is for

At WWDC in June 2023, Craig Federighi announced the iOS 17 autocorrect rewrite with this line: "In those moments where you just want to type a ducking word, well, the keyboard will learn it, too." (TechCrunch, NPR)

This database is how it learns. The symbol names in the macOS shared library cache spell out the flow:

TI::VulgarWordUsageDatabaseWrapper::set_current_app_id(CFString)
TI::VulgarWordUsageDatabaseWrapper::set_current_recipient_ids(vector<string>)
TI::VulgarWordUsageDatabaseWrapper::increment_usage_count(word, reading, recipients)
TI::VulgarWordUsageDatabaseWrapper::decrement_usage_count(...)
TI::VulgarWordUsageDatabaseWrapper::can_autocorrect(word, app, recipients)
TI::VulgarWordUsageDatabaseWrapper::can_autocorrect_somewhere(word, reading)
KB::FilterNonVulgarIfTopCandidateVulgar::filter_candidates(...)
KB::Candidate::every_vulgar_word_is_allowlisted(VulgarWordUsageDatabaseWrapper*, ...)

Plus log strings like Passing recipient IDs to the vulgar word usage database wrapper; number of IDs: %lu.

In plain terms:

  1. When you start typing, the keyboard engine tells the wrapper which app you're in and who you're writing to.
  2. When you type a vulgar word and keep it, its count goes up for that app and those recipients. Undoing the word brings the count back down.
  3. When the engine ranks suggestions, it asks whether every vulgar word in a candidate is allowlisted for this context. If it isn't, the clean alternative wins: ducking.

A _KSUserWordsSynchroniser sharedAcrossLanguagesVulgarWordUsageValue symbol suggests it syncs between devices like your other learned words.

Mine is empty

I tried to fill it. Using agent-desktop, I opened a new TextEdit document and typed well fuck this shit, damn it., first as a text insert, then as individual key presses. Nothing changed: no row, no log line, and the file's modification date stayed in 2024.

I don't know why, and I couldn't work it out. Maybe the Mac never records anything here, or maybe it only does so in some apps or settings. If you've got rows in yours, tell me what you did.

That would be a short article. But the database only counts words that are already known to be vulgar. So who decides that?

Where the word list lives

Searching the dyld shared cache for "vulgar" turned up a C API with an LX prefix (LXVulgarWordUsageDatabaseCreate, LXVulgarWordUsageDatabaseCanAutocorrect, …) and, more usefully, two functions on dictionary entries:

_LXEntryGetCategoryFlags
_LXEntryGetMetaFlags

LX is Lexicon.framework, a private framework that reads the per-language dictionaries under /System/Library/LinguisticData/. Every word in those dictionaries is an LXEntry with two 32-bit flag fields. If the vulgarity rating is anywhere, it's in those bits.

There is no public API

I looked. NSSpellChecker, UITextChecker, NaturalLanguage and UILexicon don't expose vulgarity. SensitiveContentAnalysis covers images and video, not text. The only places "profanity" shows up on developer.apple.com are App Store age ratings and review guidelines.

So: private functions. Lexicon has no headers, but the C functions are exported, and their signatures are easy to guess because they follow CoreFoundation conventions. This program opens the dictionary for a locale, walks every entry, and prints both flag fields:

// lx.c — dump every word in a system lexicon with its flags.
// clang -O1 -o lx lx.c -F/System/Library/PrivateFrameworks -framework Lexicon -framework CoreFoundation
#include <CoreFoundation/CoreFoundation.h>
#include <stdbool.h>
#include <stdio.h>

typedef const void *LXLexiconRef, *LXCursorRef, *LXEntryRef;
extern CFStringRef kLXLexiconLocaleKey;
LXLexiconRef LXLexiconCreate(CFDictionaryRef options, CFErrorRef *error);
LXCursorRef  LXLexiconCreateRootCursor(LXLexiconRef);
void         LXCursorEnumerateEntriesRecursively(LXCursorRef, void (^)(LXEntryRef, bool *stop));
CFStringRef  LXEntryCopyString(LXEntryRef);
uint32_t     LXEntryGetCategoryFlags(LXEntryRef);
uint32_t     LXEntryGetMetaFlags(LXEntryRef);

int main(int argc, char **argv) {
  CFStringRef locale = CFStringCreateWithCString(NULL, argc > 1 ? argv[1] : "en_US", kCFStringEncodingUTF8);
  const void *keys[] = {kLXLexiconLocaleKey}, *values[] = {locale};
  CFDictionaryRef options = CFDictionaryCreate(NULL, keys, values, 1, &kCFTypeDictionaryKeyCallBacks,
                                               &kCFTypeDictionaryValueCallBacks);
  LXLexiconRef lexicon = LXLexiconCreate(options, NULL);
  if (!lexicon) return 1;

  LXCursorEnumerateEntriesRecursively(LXLexiconCreateRootCursor(lexicon), ^(LXEntryRef entry, bool *stop) {
    char word[512] = "";
    CFStringRef s = LXEntryCopyString(entry);
    if (s) { CFStringGetCString(s, word, sizeof word, kCFStringEncodingUTF8); CFRelease(s); }
    printf("%08x\t%08x\t%s\n", LXEntryGetCategoryFlags(entry), LXEntryGetMetaFlags(entry), word);
  });
}
$ ./lx en_US | wc -l
  199019
$ ./lx en_US | awk -F'\t' '$3 == "fuck" || $3 == "table" || $3 == "Trump"'
00000000	00800002	fuck
00000000	00000000	table
00002000	02000081	Trump

The same thing in Swift, loading the functions at runtime with dlopen and dlsym so you don't need to link against a private framework. It prints only words that carry one of the three tiers:

// lexflags.swift — swiftc -O lexflags.swift -o lexflags && ./lexflags en_US
import Foundation

let lexicon = dlopen("/System/Library/PrivateFrameworks/Lexicon.framework/Lexicon", RTLD_NOW)!
func sym<T>(_ name: String, as _: T.Type) -> T { unsafeBitCast(dlsym(lexicon, name)!, to: T.self) }

typealias LXRef = UnsafeRawPointer
let create     = sym("LXLexiconCreate", as: (@convention(c) (CFDictionary, UnsafeMutablePointer<Unmanaged<CFError>?>?) -> LXRef?).self)
let rootCursor = sym("LXLexiconCreateRootCursor", as: (@convention(c) (LXRef) -> LXRef?).self)
let enumerate  = sym("LXCursorEnumerateEntriesRecursively", as: (@convention(c) (LXRef, @convention(block) (LXRef, UnsafeMutablePointer<Bool>) -> Void) -> Void).self)
let copyString = sym("LXEntryCopyString", as: (@convention(c) (LXRef) -> Unmanaged<CFString>?).self)
let metaFlags  = sym("LXEntryGetMetaFlags", as: (@convention(c) (LXRef) -> UInt32).self)
let category   = sym("LXEntryGetCategoryFlags", as: (@convention(c) (LXRef) -> UInt32).self)
let localeKey  = dlsym(lexicon, "kLXLexiconLocaleKey")!.load(as: CFString.self)

let locale = CommandLine.arguments.dropFirst().first ?? "en_US"
guard let lex = create([localeKey: locale] as CFDictionary, nil), let root = rootCursor(lex) else {
    fatalError("could not open lexicon for \(locale)")
}

let tiers: [(UInt32, String)] = [(0x0080_0000, "vulgar"), (0x0100_0000, "offensive"), (0x0200_0000, "sensitive")]
enumerate(root) { entry, _ in
    let meta = metaFlags(entry)
    guard let tier = tiers.first(where: { meta & $0.0 != 0 })?.1,
          let word = copyString(entry)?.takeRetainedValue() else { return }
    print(tier, String(format: "%05x", category(entry)), word, separator: "\t")
}

Both are read-only. They open the same dictionary the keyboard uses and print it.

Decoding the bits

Apple doesn't document these flags, so every meaning below is inferred: I grouped words by bit and read samples until each group's theme was obvious. The system code helps with one of them. An enum case called offensiveVulgarSensitive sits right next to the vulgar-word code, and three adjacent meta bits match it.

The three tiers (meta flags)

BitTieren_US wordsExamples
0x00800000vulgar1,050fuck, shit, cunt, cock, crap, wtf, fml, bullshitting, fucktard, shizzle, merde
0x01000000offensive663kike, wop, dago, fag, Paki, japs, Klansman, retard, Oriental, ass, asshole
0x02000000sensitive2,558damn, bitch, idiot, LSD, abortion, gun, suicide, anus, pee, poo

In en_US a word has at most one tier: they never overlap. Out of 199,019 entries, 4,271 have one.

Roughly: vulgar is swearing and sex slang, offensive is slurs aimed at groups of people, and sensitive is everything a keyboard shouldn't offer you unprompted: mild swears, insults, drugs, weapons, extremism, disease and bodily functions.

The topics (category flags)

The other field is a topic bitmask, and a word can have several topics:

BitTopicen_US wordsExamples
0x00100excretion123feces, barf, crapper, piddle, pooped
0x00200body parts958nipples, navel, buttock, perineum, triceps
0x00400insults, identity-loaded823idiot, cretin, hick, pleb, sexist, homophobic, blacklist
0x00800disease93Covid, herpes, PTSD, leprosy, monkeypox
0x01000drugs222heroin, shroom, crackhead, hotbox, OD
0x02000politics, extremism266Hamas, Goebbels, Leninism, democrat, swastikas, Trump
0x04000religion, damnation420Sikhism, jihadi, Beelzebub, damn, hell, jeez
0x08000sex473nude, nookie, lecher, harlots, Pornhub, Hooters
0x10000violence, death373suicide, lynch, murders, hangings, slave, slaveholder
0x20000weapons, terrorism268AK-47, grenadier, jihad, sarin, kamikaze, yellowcake
0x40000catch-all "sensitive context"1,080God, Allah, Jews, gay, trans, bi, crazy, fat, ugly, sex, die

The topic flags and the tiers are independent. 2,052 English words have a topic but no tier: gay, fat, God, Allah and sex are all tagged "sensitive context" but are not vulgar, offensive or sensitive. My guess is the topic is a hint ("be careful how you rank this") and the tier is the rule.

The lower meta bits are dull: proper nouns, abbreviations, camel-case brand names like iMac and tvOS. Nothing to see there.

The weird ones

This is where it gets fun. Some flags are obviously right, and some made me laugh.

Vulgar

  • Oedipus: vulgar. Presumably because of motherfucker-adjacent jokes, or the complex. Sophocles would be thrilled.
  • reams: vulgar. As in "to ream someone".
  • cooties: vulgar. Your nine-year-old is swearing.
  • orgy, sext, merde: fair enough.

Offensive

  • Tom and Mick: offensive. Uncle Tom and the slur for Irish people, but also very common first names.
  • Oriental: offensive.
  • papooses: offensive.
  • conservatards: offensive, the one political insult I found in the slur tier.
  • niggardly: offensive. It means "stingy" and comes from Old Norse, unrelated to the slur. It has also caused real controversies, which is presumably why it's there.
  • ass and asshole: offensive, not vulgar.

Sensitive

  • pee and poo: sensitive, with the excretion topic. So are premenstrual, leprosy and herpesvirus.
  • niggle (a small worry, of Scandinavian origin) and underling (Old English for a subordinate) are sensitive and share the insult topic. Similar words aren't flagged: snigger is clean but niggle is flagged, and minion, lackey, flunky and stooge are clean but underling is flagged. These look like hand-picked entries, not rules.
  • blacklist and whitelist are sensitive with the insult/identity topic. That matches the industry move to blocklist and allowlist. Apple's own phrase-filter files are named blocklist.dat.
  • Braves and Chiefs (team names with Native American mascots), Londonderry (the contested name for Derry), Gentile, Jehovah, Mongol and Frenchier are all sensitive.
  • Isis is sensitive with the politics topic. Sorry to the Egyptian goddess.
  • slave, slavery and slaveholder are sensitive with the violence topic.
  • idiot is sensitive in English. Norwegian is stricter and puts idiot in the offensive tier.

The Trump flag

The sensitive tier has 109 capitalized words. Here is all of it:

Al-Qaeda, Al-Qaida, Aryan, Aryans, Auschwitz, Bacchus, Beelzebub, Birkenau, Brave(s), Buchenwald, Chief(s), Chippendale(s), Christ-y, Christer(s), Crip(s), Dachau, Eichmann, Frenchier, Gentile(s), Gestapo(s), Goebbels, Goering, Hamas, Hezbollah, Himmler, Hitler, Hitlerian, Hitlerism, Hitlerite(s), Hitlerjugend, Hooters, Isis, Islamophobe(s), Islamophobic, Jehovah(s), Jewfro(s), Jezebel(s), Kalashnikov(s), Kristallnacht, Kuomintang, Londonderry, Luftwaffe, Mao, Mauthausen, Mickie(s), Mollie(s), Mongol(s), Mossad, Nazi(s), Nazism, Osama, Pornhub, Quaalude(s), Reich, Reichsmark, Reichstag, Sachsenhausen, Satan, Satanism, Shoah, Sinophobe(s), Sinophobic, Sodom, Stasi, Taliban, Treblinka, Trump, Trumps, Unabomber, Uzi(s)

(Plurals and possessives folded for brevity.)

Every other real person on that list is dead, and nearly all of them are Nazi officials, dictators or terrorists. Then there's Trump:

category  meta      word
00002000  02000081  Trump        sensitive tier + politics topic
00002000  02000081  Trumps
00002000  02000081  Trump's
00000000  00000000  trump        (the card-game word: clean)
00000000  00000000  trumped

For comparison:

Worden_US
Trumpsensitive, politics topic
Hitlersensitive, politics topic
Bidenclean
Obamaclean
Putinclean
Stalinclean
Musknot in the dictionary

en_GB matches en_US. Other languages differ: the German dictionary has Trump clean (and puts Hitler in the offensive tier), and Swedish is the one that flags Putin as sensitive.

What the flag does matters here. As far as I can tell, a sensitive word is one the keyboard shouldn't suggest on its own. You can still type it and it won't be corrected away. Think "keep this out of the QuickType bar unless the user is clearly heading there", which is a lot milder than a ban.

There is a plausible reason Apple would want that for one specific name. In February 2025, iPhone dictation briefly showed "Trump" when people said "racist". It went viral, and Apple called it a "phonetic overlap" in the speech model and shipped a fix within a day (9to5Mac). The dictionary files on my Mac are from July 2025. A rule that keeps the name out of unprompted suggestions would stop that kind of thing from happening again through the keyboard. I can't prove that's why it's there, and I don't have an older dictionary to compare against. But it fits.

Norwegian politics, for comparison

Because I'm Norwegian and had to check:

WordTopicTier
FrP, Høyre, Venstre, Rødt, SV, KrF, MDGpoliticsnone
Størepoliticsnone
Solberg, Erna, Sylvinonenone
Listhaugn/anot in the dictionary
Breivik, Quisling, Hitlernonesensitive

Every party gets the politics topic and none gets a tier. The only Norwegians in the sensitive tier are a mass murderer and a Nazi collaborator.

Other languages

The same three tiers exist in every European dictionary I tried, Cyrillic included. The counts are entries, not lemmas, so heavily inflected languages like Polish and Russian look much worse than they are: every case and conjugation of a swear word is a separate entry.

LocaleVulgarOffensiveSensitiveNotable
en_US1,0506632,558idiot is sensitive
en_GB1,0558722,337chav is offensive; chink is filed as vulgar, not offensive
nb_NO2,3931,3246,446idiot is offensive
da_DK1,1793,3148,551fjols ("fool") is offensive
sv_SE6371,24716,193idiot is vulgar; Putin is sensitive
fi_FI56987247The entire sensitive tier is kakka ("poop"), kakkakasa ("pile of poop") and saakeli
de_DE3,2183,5189,446Hitler and beknackt ("daft") are offensive; Trump is clean
nl_NL571,1803,805Only 57 vulgar words, but kanker ("cancer", the classic Dutch swear) is one
fr_FR2,1512625,562boche is offensive
es_ES3,93131313,431sudacas and fachas are offensive
it_IT3,8863776,543terrone is offensive, and so is nazismi
pt_BR1,5901,8913,332machista and velhinhos ("little old people") are offensive
pl_PL9,6981,4366,190The biggest vulgar tier here, mostly inflections (dupczysz, srasz)
ru_RU10,8213,23924,721Same story: мат inflects a lot

The bits don't seem to mean the same thing everywhere. Arabic and Hebrew return hundreds of thousands of "sensitive" entries, so those dictionaries probably reuse the bits for something else, and I left them out. Korean, Catalan and Hungarian return nothing, and Japanese crashes my dumper.

Samples from Norwegian, since that's what I type in when I'm not typing English:

  • Vulgar: faen, jævla, dritt, fitte, kuk, pule, pissehull, rævhull, pulekanin, kåt, morrabrød.
  • Offensive: idiot, sigøyner, kinamann, riskokers (literally "rice cookers", a slur for Asians), plus the Norwegian n-word.
  • Sensitive: helvete, satan, homo, quisling, Breivik, skinhead, abortnekt, trusene ("the panties").

The tier words are mostly what you'd expect, but the edges are different in each language, and that is where the human-edited parts show. Norwegian calls idiot offensive and English calls it sensitive. German thinks Hitler is a slur.

Phrases, not just words

There is a second, separate filter. Every English language model has a blocklist.bundle, and there's a qp_blocklist.bundle for QuickPath, the swipe keyboard:

/System/Library/LinguisticData/RequiredAssets_en.bundle/AssetData/en.lm/
├── blocklist.bundle/{blocklist.dat, blocklistidmap.dat}
└── qp_blocklist.bundle/{blocklist.dat, blocklistidmap.dat}

blocklistidmap.dat is a 20-byte header followed by (lexicon token ID, class ID) pairs of 32-bit integers. Mapping the token IDs back through the dictionary gives 2,544 words in 930 classes. Most classes are inflection groups:

931   fuck | fucked | fucking | fucks
1181  molest | molestation | molested | molester | molesting
1514  died | dies | dying
1611  Holocaust | holocaust | holocausts
1614  denied | denies | deny | denying
923   Semite | Semites | Semitic
1487  ghetto | ghettoes | ghettos
1720  animals | brown | cats

blocklist.dat is a packed trie over those class IDs, so it blocks sequences: "deny" followed by "Holocaust", and whatever 1720 pairs with. A class that puts brown next to animals and cats suggests the list targets specific phrasings rather than single words, though which phrasings I can't tell without decoding the trie. The runtime check is LMLanguageModelTokenSequenceIsBlocklisted in LanguageModeling.framework. I got far enough to learn that its fourth and fifth arguments include an output pointer, but my probe still crashes intermittently, so I haven't read the actual phrases yet. That's for a follow-up.

Caveats

  • Meanings are inferred. Apple documents none of these bits. The tier names come from an enum in the system code and the topics from reading thousands of samples. Both are consistent, but they're still a reading.
  • Flagged is not censored. As far as I can tell these flags decide what the keyboard volunteers, not what you can type.
  • One machine. Everything here comes from macOS 15.6 (24G84) with LinguisticData build 438 from July 2025. iOS and other macOS versions may ship different dictionaries.
  • Private API. The functions can change or disappear in any update. Don't ship this in an app.

Try it yourself

# Your own learned-swears database (probably empty on a Mac)
sqlite3 -readonly ~/Library/Keyboard/VulgarWordUsage.db 'select * from vword_usage'

# Build the Swift version from above and filter by tier
swiftc -O lexflags.swift -o lexflags
./lexflags en_US | awk -F'\t' '$1 == "vulgar" {print $3}' | sort -u | less
./lexflags nb_NO | awk -F'\t' '$3 == "faen" || $3 == "helvete" || $3 == "idiot"'

If you'd rather not build anything, the gist has every tiered word for 15 locales as TSV, plus the code and a script that regenerates them. It's a snapshot of macOS 15.6 and won't be kept up to date.

If you find something funnier than Oedipus, I want to hear about it.


来源:Hacker News-首页 · helgesver.re