The zh pair's other direction, and a rule pack that mostly says no

`pair_lang` had always been answering a second question nobody asked: it
says which two languages, and every surface built on it assumed English
was the one being learned. That is why hanzi is never tokenized, never
spell-checked, never glossed — correct for a Mandarin native practising
English, backwards for an English native practising Mandarin.
`users.direction` (migration 0016) separates the two questions; a
`zh-learner` pair code would have been cheaper and would have made two
directions of one pair look like two unrelated languages to every query.

Segmentation is what replaces `wordAt` where there are no spaces: a
shortest-path walk over log-probabilities, 232 ms and 14 MB for 188,522
words. The browser gets the word list because segmentation runs on hover;
the server keeps the whole dictionary. Their coverage gates come out
opposite on purpose — the client list is frequency-gated because the
segmentation is measurably identical without the tail, and the dictionary
is gated by nothing, because its only power is to explain and the word a
learner stops on is the rare one.

The 错别字 pack is 24 confusable pairs behind two mechanical gates. One
admits a pair only if the wrong form is not a dictionary word and the
right form is, which is why it refuses 自已 for 自己 — a real error whose
wrong form is a headword. The other asks the segmenter whether the two
characters already belong to two different words, without which 自己经常,
睡觉的时候 and 不知到底 would all be corrupted silently into text still
made of real characters.

Not deployed (this carries a migration), not seen in a browser, and no
account has ever been in the learner direction. The IME composition
guards were in scope and are not done — see BUILD_PLAN Phase 26.
This commit is contained in:
prosolis
2026-07-28 19:04:53 -07:00
parent 9224c44fff
commit 77f284f65c
35 changed files with 2218 additions and 44 deletions
+35
View File
@@ -210,6 +210,21 @@ export interface PersonalWords {
words: string[]
}
// One pronunciation of a Chinese word, and what it means in that pronunciation.
// A list, because 得 is dé "to obtain" and also the particle in 说得很好.
export interface HanziReading {
pinyin: string
senses: string
}
// A Chinese word lookup. `readings` is empty for a word with no headword, in
// which case `chars` may carry the character-by-character reading.
export interface HanziInfo {
word: string
readings: HanziReading[]
chars: { char: string; pinyin: string; senses: string }[]
}
// Who's writing. Mirrors the backend db.User.
export interface Me {
id: string
@@ -217,6 +232,11 @@ export interface Me {
display_name: string
created_at: string
pair_lang: string
// Which half of the pair is being learned: 'learning_en' (the writer is
// native in pair_lang and practising English) or 'learning_pair' (the other
// way round). Mirrors users.direction; the server refuses 'learning_pair' for
// a pair it has no word list for.
direction: string
}
// Thrown when the server says the session is gone. Callers can tell it apart
@@ -269,6 +289,15 @@ export const api = {
setPairLang: (lang: string) =>
req<Me>('/me', { method: 'PATCH', body: JSON.stringify({ pair_lang: lang }) }),
// Turn the pair around. Same endpoint, same contract, and deliberately a
// separate call: the two fields are validated together server-side, so a
// client that wants to change both says both in one request rather than
// sending two that each pass on their own.
setDirection: (direction: string) =>
req<Me>('/me', { method: 'PATCH', body: JSON.stringify({ direction }) }),
setPair: (lang: string, direction: string) =>
req<Me>('/me', { method: 'PATCH', body: JSON.stringify({ pair_lang: lang, direction }) }),
listDocs: () => req<DocSummary[]>('/docs'),
createDoc: () => req<Document>('/docs', { method: 'POST' }),
getDoc: (id: string) => req<Document>(`/docs/${id}`),
@@ -348,6 +377,12 @@ export const api = {
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
// and offline, so it fires on hover without spinning up the heavier lookup.
glossWord: (word: string) => req<Gloss>(`/gloss/${encodeURIComponent(word)}`),
// The same lookup pointing the other way: a Chinese word to its pinyin and
// English senses, for an account learning the pair language rather than
// English. A word the dictionary has no headword for comes back with empty
// readings and — when its characters are known — a per-character reading
// instead, which is a real second answer for a compound.
hanziWord: (word: string) => req<HanziInfo>(`/hanzi/${encodeURIComponent(word)}`),
// Tone-rewrite: rewrites a selected passage in the given style ('natural',
// 'academic', …) and returns the rewritten text for an in-editor preview. Not
// persisted — the editor applies it directly on accept.