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:
@@ -367,10 +367,40 @@ Scope agreed with the user 2026-07-28: **Latin American neutral, quorum review,
|
||||
- Verified: go build/vet, `go test ./...` clean, tsc, vite build, **vitest 251/251**. ⚠️ **Not seen in a browser** — no Chrome extension on this laptop; the 1.59 MB dictionary inflating in a real tab and the picker's fourth entry in a real mobile drawer are what unit tests cannot cover.
|
||||
- **Not deployed.** No migration, so it is a rebuild whenever the user wants it; `piper-es` wants `docker compose up -d piper-es` and a voice download on the box. **No es account exists**, and all three accounts are still on `zh` — flipping a pair is hers to do from the picker.
|
||||
|
||||
### Phase 26 — the zh pair's other direction (2026-07-28, code half) — segmentation, and a rule pack that mostly says no
|
||||
Scope agreed with the user 2026-07-28: **a `direction` column, segmentation + hover pinyin/gloss, 错别字 detection, IME guards** — code only, and this one carries a migration, so deploy is its own step. SUGGESTIONS §4 called this "its own phase with its own spec" and "Petal's next big product bet"; it is also the first phase whose user is the *other* writer — the one learning Chinese rather than the one learning English.
|
||||
|
||||
1. [x] **`users.direction` (migration `0016`), and why it is a column rather than a pair code.** `pair_lang` has always answered "which two languages" and every surface built on it quietly assumed the answer to a second question nobody asked: that **English is the language being learned**. That assumption is load-bearing in a dozen places — CJK is deliberately never tokenized, never spell-checked, never glossed; the prompts explain English in her language; the garden captures English words. All correct for a Mandarin native practising English, all backwards for an English native practising Mandarin. A second pair code (`zh-learner`) was cheaper and the wrong shape: it makes two directions of one pair look like two unrelated languages to every query, and it would have to be repeated for fr, es and pt-PT before any of them could turn around. The backfill is the DEFAULT itself, and it is right rather than merely convenient — all three accounts today really are Mandarin natives writing English.
|
||||
- **`PATCH /api/me` validates the two fields as one decision.** Both are optional and each defaults to what the account has, which is what makes the picker able to send one without knowing the other — and it is exactly that convenience the endpoint has to protect against: a client sending only `pair_lang: "fr"` while the account sits on `learning_pair` is asking for French-with-segmentation, a state neither field names on its own. **Refused, not silently downgraded**: a downgrade leaves the writer looking at an editor that behaves like the one she just tried to leave, with nothing to read as an explanation.
|
||||
- **`auth.learnerPairs` is a third list, and deliberately not either of the two that exist.** `internal/llm`'s languages name every pair the *prompts* can discuss; `shippedPairs` names every pair Petal can *render itself in* (needs a langpack); this one names every pair Petal can be *learned toward*, which needs a word list and a dictionary reading out of that language. zh has both; fr, es and pt-PT have neither, and their failure mode is worse than a missing pack — a missing pack shows unreadable copy, a missing word list shows an editor that silently does nothing when you hover.
|
||||
- An empty `PATCH` body used to be a 400 and is now a 200 that changes nothing. That is a real contract change and it is the price of optional fields; it has its own test saying so.
|
||||
2. [x] **Two assets, split so their coverage decisions come out opposite** (`scripts/build_cedict.py`, CC-CEDICT + jieba's `dict.txt`). Neither source has both halves: CC-CEDICT has headwords, pinyin and senses and *no frequencies*; jieba has 349k headwords with frequencies and *no definitions*. Segmentation needs the frequencies, because the algorithm is a shortest-path walk over log-probabilities and not longest-match.
|
||||
- **The browser gets the word list** (`words.txt.gz`, 188,522 words, **0.97 MB gzipped**) because segmentation runs on hover and a round-trip per hover is not a hover. **The server holds the whole dictionary** (`hanzi.json.gz`, 113,637 entries, **3.12 MB gzipped**, its own `sync.Once` so only a learner account pays for it).
|
||||
- **The client gate is a size decision and was measured as one.** Segmentation by the full 381,886-word union and by a frequency-gated list is **identical** on ordinary learner prose, including the textbook ambiguities (研究生命的起源, 乒乓球拍卖完了, 南京市长江大桥) — the long tail is rare proper nouns, and a rare word loses to two common ones every time. So the gate sits at freq ≥ 5, with **every CC-CEDICT headword unioned back in** so the segmenter can always see a word the server can explain.
|
||||
- **The dictionary gate is nothing at all, for the opposite reason.** The es phase settled that a *spelling* dictionary holds the union of every variety because its only power is to underline. This asset's only power is to **explain**, and the word a learner stops on is precisely the one they do not know — which is to say, the rare one. Trimming it by frequency would remove exactly the entries it exists for.
|
||||
- **Pinyin is tone-marked here, not at render time**, and `MAX_READINGS = 2` is not arbitrary: 得 is dé "to obtain" *and* de, the complement marker, and a learner who hovers 得 in 说得很好 and is told only "to obtain" has been actively misinformed about the sentence in front of them. The build script asserts all three of 的/地/得 carry their neutral-tone reading.
|
||||
- **Simplified only, said out loud.** Glossing traditional would be nearly free here and useless in the app: nothing would segment it, so nothing would ever ask.
|
||||
3. [x] **The segmenter** (`web/src/lib/segment.ts`) — shortest-path over log-probabilities, `MAX_WORD_LEN` 6, unknown single characters scored at half an occurrence (positive, so every position has *some* path; below the rarest real word, so it never wins). **369 ms/74 MB was fr's Hunspell cost; this is 232 ms and 14 MB** for a bigger language, because a flat word list needs no affix machinery. `hanziWordAt` (`hanziWord.ts`) resolves it to ProseMirror positions through **the same `mapOffset`** the suggestion, spell and search layers anchor with, plus its inverse.
|
||||
- **Writing the tests found the boundary bug.** A position names a *gap* and a word covers *characters*, so a hover on a boundary was resolving to the word that **ended** there rather than the one that starts — index 6 of 我今天去公园跑步了 is the 跑 under the mouse. The step-back to the left-hand character is kept for exactly one case: the caret at the end of the text, which is where it sits the instant an IME commits a word.
|
||||
- Tested twice over: hand-built dictionaries pin the *algorithm* (they would pass with any word list), and a block at the bottom pins the **shipped asset** on the sentences a rebuild would plausibly break — including the minimal pair 研究生宿舍 / 他们正在研究生物, which is what says the 研究/生命 result was a decision and not a bias against long words.
|
||||
4. [x] **Hover pinyin + English gloss, as an adapter rather than a second card.** `GET /api/hanzi/{word}` and one new prop each on `GlossTip` (a `lead` line above the meaning) and `WordCard` (`pinyin`, rendered **without** the slashes, because pinyin is not a phonetic transcription and the slashes would say something untrue in the one place a learner is looking for the truth about pronunciation). Everything else is reuse: same anchoring, same garden capture, same 🔊 — the zh pair already speaks Chinese, so reading 公园 aloud needed nothing.
|
||||
- **The character fallback.** The word list is a superset of the dictionary, so a hover really can land on a real word with no headword; Chinese compounds are usually transparent from their parts, which makes the per-character reading a real second answer. Returned in its own field so the surface can say which it is showing — and `hanziPinyin` stays empty in that case on purpose, since 不 is bù alone and bú before a fourth tone, and joining character readings would be inventing a pronunciation.
|
||||
5. [x] **错别字 — and the pack's most interesting property is what it refuses.** Chinese has no misspellings in the Hunspell sense: every character an IME offers is a real character, correctly formed. The error is a **substituted character inside a correct-looking word**, so this is a rule pack over confusable pairs, filed as the existing `mechanics` family (same rail, same cards, no new colour) and gated on the segmenter's presence — which *is* the direction gate, so a writer practising English can never be told her quoted Chinese is wrong.
|
||||
- **Gate one: the pair must be decidable by the dictionary** — `wrong` absent from the 188k list, `right` present, checked against the shipped asset in the suite rather than asserted in a comment. This is what keeps out errors everyone knows are errors: **自已 for 自己 is among the commonest slips in written Chinese and 自已 is itself a headword**, so the pack does not flag it — exactly as Phase 22's English pack left out `married with`. Same fate for 好象, 倒底, 帐号 and 部份. 24 pairs survived out of ~50 screened.
|
||||
- **Gate two: the characters must not already belong to two different words,** and without it every rule is dangerous. 自己经常 contains 己经. 睡觉的时候 contains 觉的. 不知到底 contains 知到. A substring match corrupts all three — silently, into text still made of real characters. The segmenter already knows the difference: if the two characters land in different tokens and either is a real multi-character word, that is a word boundary; two adjacent single-character tokens is what the walk produces when it has nothing better, which is what a mistyped compound looks like.
|
||||
- **Where the gate costs a real catch, it pays.** 我不知到他在哪里 really is 知到 for 知道 and is left alone, because 不知 is itself a word — while 我不知到底该怎么办 is the same three characters and is correct. The test is named for that trade rather than for the rule.
|
||||
- Server-side, `TestOfflineHanziFindingStaysMechanics` pins the one rule a layer above that would plausibly claim it: `isTranslation` re-labels an edit whose original reads as her language and whose replacement reads as English. 己经 → 已经 looks like the first half of that and nothing like the second, and must stay a tidy-up in her own sentence.
|
||||
6. [x] **The direction picker names each option in the language of the person who would choose it** — 英文 for the writer who is native in Chinese, "Chinese 中文" for the one who is native in English. The same self-naming principle the pair buttons follow, for the same reason: someone on the wrong side of this switch cannot read the side they are trying to reach. It renders only when the pack carries a `learner` block, which is the frontend's half of `auth.learnerPairs`.
|
||||
- **`@types/node` added as a devDependency**, which is a small thing with a real consequence: vitest can now read the *shipped* assets. Phases 21–25 all verified their dictionaries with throwaway scripts because the suite could not; `segment.test.ts` and `hanzi.test.ts` assert against the real files.
|
||||
- Verified: go build/vet, `go test ./...`, tsc, vite build, **vitest 284/284**; live smoke on a throwaway DB (:8071, LLM pointed at a dead port) — 公园 → gōngyuán, 得 → both readings, 猫书 → the character fallback, the word list served at 965,266 B, `PATCH {"direction":"learning_pair"}` accepted, `{"pair_lang":"fr"}` refused 400 while the account stayed put. The smoke also caught a cosmetic build bug: stripping CC-CEDICT's `CL:` field left "cat (" with an unbalanced paren, now fixed and the asset rebuilt.
|
||||
- ⚠️ **The IME guards were scoped into this phase and are NOT done.** No composition handling exists anywhere in the app — verified, not assumed. The risk is concrete and known: the decoration plugins (`SpellCheck`, `SuggestionHighlight`, `SearchHighlight`) recompute on every doc change, and a rebuild mid-composition is the classic bug that eats half-typed pinyin. This is the single most likely thing to be wrong the first time someone types Chinese into Petal with a real IME, and it is untouched rather than half-built.
|
||||
- ⚠️ **Not deployed** (this one carries a migration, so it is a deploy and not a rebuild), **not seen in a browser**, and **no account has ever been in the learner direction** — every claim above about how this feels to use is inference from unit tests. The 错别字 pack has not been read by a native speaker either; unlike the Latin packs it is 24 mechanically-screened pairs rather than prose, which lowers the stakes without removing them.
|
||||
|
||||
### Later / explicitly not now
|
||||
- Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 19–21 prove the pair model
|
||||
- ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26**, **shipped (code) 2026-07-28** — see Phase 25. What it still owes: a deploy, a native reader, and a writer who actually uses it.
|
||||
- ~~Voseo for the es pair~~ **resolved 2026-07-28 before shipping** — the fix was not to generate the paradigm but to stop using Debian's package, which is peninsular. RLA's generic build has it. See Phase 25 item 2.
|
||||
- **IME composition guards** — scoped into Phase 26 and not built; see that entry. The decoration plugins recompute on every doc change, and doing so mid-composition is what eats half-typed pinyin. First thing to do before anyone types Chinese into Petal in earnest.
|
||||
- **`restoring` is untranslated in the fr and pt-PT packs** — surfaced by the es review, fixed only in es. One line each, whenever those packs are next touched.
|
||||
- Reactive-animation puppy companion — wishlist, low priority; `companions.ts` roster + mood engine is the drop-in point
|
||||
- Copyleaks Tier-2 — revisit once Phase 15 provides a public webhook endpoint
|
||||
@@ -383,6 +413,7 @@ Scope agreed with the user 2026-07-28: **Latin American neutral, quorum review,
|
||||
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
|
||||
|
||||
## Session log
|
||||
- 2026-07-28: **Phase 26 — the zh pair's other direction, and a rule pack whose best feature is what it refuses** (user asked to continue the build plan, then chose a new phase over deploying fr/es; scope chosen with the user: **direction column, segmentation + hover pinyin/gloss, 错别字, IME guards**, code only). SUGGESTIONS §4 had called this its own epic, and the reason turned out to be one sentence: **`pair_lang` had always been answering a second question nobody asked.** It says which two languages; every surface built on it assumed English was the one being *learned*, which is why CJK is deliberately never tokenized, never spell-checked and never glossed. All correct for the writer this app was built for, all backwards for the other one. A `direction` column rather than a `zh-learner` pair code, because the two are genuinely separate questions and the column is what lets fr/es/pt inherit the capacity later. **The phase has three decisions in it and they are all about coverage.** The browser gets a word list and the server keeps the dictionary, and their gates come out *opposite*: the client list is frequency-gated at 5 because segmentation by the full union and by the gated list is **identical** on ordinary prose (measured, including 研究生命的起源 and 乒乓球拍卖完了 — the long tail is rare proper nouns and the max-probability walk never picks one), while the dictionary is gated by **nothing**, because its only power is to *explain* and the word a learner stops on is precisely the rare one. That is the es dictionary decision arrived at from both sides in one phase. **Writing the segmenter tests found the boundary bug**: a position names a gap and a word covers characters, so a hover on a boundary was resolving to the word that ended there rather than the one that starts. **The 错别字 pack is the part worth reading.** Chinese has no misspellings — every character an IME offers is real — so the unit of error is a substituted character inside a correct-looking word, and the pack is 24 confusable pairs held by two mechanical gates. Gate one admits a pair only if the wrong form is *not* a dictionary word and the right form is, which is what makes it refuse **自已 for 自己** — one of the commonest slips in written Chinese, whose wrong form is itself a headword — exactly as Phase 22 refused `married with`. Gate two is the one that matters: **自己经常 contains 己经, 睡觉的时候 contains 觉的, 不知到底 contains 知到**, so a substring match would corrupt correct sentences silently, into text still made of real characters. The segmenter settles it — two adjacent single-character tokens is what the walk produces when it has nothing better, which is what a mistyped compound looks like — and where the gate costs a real catch (我不知到他在哪里 *is* 知到 for 知道, but 不知 is a word) it declines rather than risk the identical-looking correct sentence beside it. **`@types/node` went in as a devDependency and quietly fixes something older**: phases 21–25 each verified their shipped dictionary with a throwaway script because vitest could not read files; the suite now asserts against the real assets. go build/vet/test, tsc, vite, **vitest 284/284**, live smoke on a throwaway DB which itself caught a cosmetic build bug ("cat (" left by stripping CC-CEDICT's CL: field). ⚠️ **The IME guards were in scope and are not done** — no composition handling exists anywhere in the app, and a decoration rebuild mid-composition eating half-typed pinyin is the likeliest thing to be wrong the first time anyone types Chinese into Petal for real. Left untouched rather than half-built, and named here rather than buried. ⚠️ **Not deployed** (it carries a migration), **not seen in a browser**, and **no account has ever been in the learner direction**, so everything above about how it feels to use is inference from tests.
|
||||
- 2026-07-28: **Phase 25 — the es pair, and a plan that had quietly chosen the wrong Spanish** (user asked where Spanish support had gone, then "yes" to starting the phase; scope chosen with the user: **Latin American neutral**, quorum review, code only). The starting point was a misreading worth recording: the plan *reads* as though Spanish shipped, because the DreamDict rebuild, the LLM language entry, the L1 rule gating and the TTS env-discovery are all `[x]` — every piece of groundwork was done and the pair itself had never been built. `shippedPairs` was the honest answer all along: the server had been refusing `es` on purpose. **The regional question was the phase.** pt-PT's was forced by packaging and fr's turned out not to exist; es had a real choice with no default, and once the user chose Latin American, the plan's own two concrete decisions were both wrong. It warned that `hunspell-es` is "packaged per country — check what `es_ES` actually is": it ships twenty country codes and **every one is a symlink to one pan-Hispanic file**, so the trap was not there. And it named **`es_ES-davefx-medium`** for the voice, which *is* the trap — six of Piper's nine Spanish voices are peninsular, so the obvious pick would have read Latin American copy in a Castilian accent, the pt-PT mistake arriving through a different door. `es_MX-ald-medium` instead. **Then the user asked "should we pick a different Spanish dictionary?" and the answer was yes** — the phase had shipped the wrong one and written a confident justification for it. Debian's `hunspell-es` symlinks twenty country codes to one file, which reads as pan-Hispanic; RLA actually publishes twenty-four builds per release, one per country **plus a generic `es` that is the union**, and Debian ships **peninsular `es_ES`**. The 58,622-form difference is essentially **voseo**: under the first build, *vení* and *tenés* — the ordinary present tense of Argentina, Uruguay, Paraguay and much of Central America — were underlined as misspellings, and this document called that a known gap handled on principle. **What makes it worth writing down is that the MUST_ACCEPT list was designed to catch exactly this and could not**: it asserted the pan-Hispanic *vocabulary*, and every RLA variant carries the full pan-Hispanic vocabulary — only the paradigms are localised — so it was satisfiable by all twenty-four. The `REP` table cited as the corroborating witness (yeísmo, seseo) is likewise shared by every build. Two independent-looking proofs, neither of which could distinguish anything, agreeing with each other. The profile now demands **voseo** (rejects es_ES and Debian), **vosotros** (rejects es_MX) and **another region's everyday words** — *arepa*, *chévere*, *bacán* (rejects es_AR, which has both paradigms and would otherwise pass); all four neighbours were run through it and confirmed refused. Shipping the union is the same call fr made between *coût* and *cout*: the dictionary's only power is to underline, so it holds every variety, while the *copy* picks a register because speaking requires one. 717,640 forms, 1.74 MB gzipped, **762 ms / 97 MB** in a real nspell, and **fr and pt-PT rebuild byte-identical** from their own upstream debs. **The quorum review earned its place twice**: four models, ≥2-of-4, 5 of 27 findings applied — one of which caught the pack's bedtime proverb being *Qui dort dîne* calqued into Spanish, English gloss and all, which is exactly the "a pack is not a translation of another pack" rule the fr header states and I had broken while writing it. And one below-threshold finding (a missing `¡` on an exclamative, seen by 1 of 4 because an absent *opening* mark has no closing `!` to look wrong against) was applied anyway and **turned into an assertion**: the suite now rejects any native line that closes `?`/`!` without opening one. That is Phase 24's lesson one level up — what a review finds once, a test should find every time. go build/vet/test, tsc, vite, **vitest 251/251**. ⚠️ **Not deployed, not seen in a browser, not read by a native speaker, and no es account exists** — all four accounts' worth of Spanish experience is still hypothetical, and the pack says so in its own header.
|
||||
- 2026-07-27: **Phase 24 — the fr pair, and a "generalizes" that did not** (user: "resume the build plan"; scope chosen with the user: French end to end, code only, deploy its own step). The plan's five items were meant to be mechanical, and four of them were — the Piper voice is a compose service and two env lines because Phase 21 made a language configuration; the lexicon needed nothing at all, fr having been measured at 63.1% during Phase 20's rebuild, better than the pair that already shipped; the sidebar picker grew a third entry without a line of UI because it derives itself from the shipped packs. **Item 3 was the one that had been recorded as done and wasn't.** `build_ptpt_dictionary.py` was said to generalize; it handled single-character flags and plain PFX/SFX and stopped on everything else, and `fr.aff` uses four of the things it stopped on. `FLAG long` is the dangerous one: French flags are two characters, so the pt-PT reader's `set(flagstr)` yields a bag of unrelated letters and expands every entry through the wrong paradigm without erroring. Plus continuation flags (French really does affix an affixed form), NEEDAFFIX on 68,075 of 84,140 stems, and FULLSTRIP. The rewritten `build_hunspell_dictionary.py` carries a per-language profile and asserts that CIRCUMFIX and FORBIDDENWORD are still unused — and **rebuilds pt-PT byte-identical to the shipped asset**, which is the only thing that makes "generalized" a claim rather than a hope. **The second decision was elision, and it was made by measuring both halves**: keeping `l'arbre` and its thirty-three siblings costs 3,159,832 forms and 8.25 MB gzipped; dropping them costs 473,326 and 1.19 MB. They are not new words, but the tokenizer keeps internal apostrophes, so they really would have been underlined — so they moved out of the dictionary and into `withElision`, which splits at a known clitic and still requires the remainder to be a word (`l'zzzz` stays flagged). Real nspell: 369 ms and 74 MB for the larger language, against pt-PT's 842 ms and 139 MB. **Where the regional trap lives is the mirror image of Portuguese's**: every `fr_*` Piper voice is fr_FR and every Debian fr dictionary is one shared word list, so nothing can be quietly wrong about the country — the whole decision is in the copy, which is why the pack is greped for *courriel* and *magasiner* the way pt-PT is greped for *arquivo*. What French does have instead is the 1990 reform, packaged three ways; Petal ships comprehensive, because Petal never corrects her French and *coût* and *cout* are both correct. go build/vet/test, tsc, vite, vitest 190/190. **Two things owed and both said plainly**: no native speaker has read the pack (SUGGESTIONS §3's bar, unmet for pt-PT too), and nothing here has been seen in a browser. **Then, same session, an interim answer to the first of those** (user: "perhaps for now, we could leverage multiple LLMs to act as reviewers?"): four models reviewed each Latin pack independently, and only findings ≥2 of them reached on their own were applied — five per pack. It earned its keep on the pack that was *already shipped*: pt-PT had **pre-Acordo spellings in a file whose own header commits to post-Acordo**, because the Phase 21 greps checked for Brazilian vocabulary and never checked the pack against its own spelling policy. That grep now exists and was confirmed to fail on the old text. Where reviewers agreed a line was wrong but split on the fix, the wording is mine and the reasoning is in the phase entry rather than averaged away. Still not a native speaker, and both packs now say so precisely.
|
||||
- 2026-07-27: **Phase 22 finished — the build plan's last four items, and the LLM stops holding anything hostage** (user: "let's finish the last phase of the build plan"; code only, no VPS work). The four remaining items shared one theme, and it only became visible while building them: **§6's left-hand column is now complete.** Spell, define, gloss, pronounce, catch the common mistakes, review vocabulary, prove authorship — every daily-writing need works with the tunnel down. **The plan asked for "grammar lite as a fourth suggestion family", and the fourth family already existed**: Phase 8's deterministic `mechanics` pass was the plumbing, so this was the rule pack it had been waiting for rather than new machinery — preposition pairs, doubled comparatives, `people is`, plus per-pair L1 interference. **Q6 answered by hand-curating rather than mining LanguageTool**: that corpus is broad because it aims at recall, and this pack aims at the exact opposite, so every entry is a pairing wrong in essentially *all* contexts and the ones only *usually* wrong were left out on purpose — `married with` is a mistake until "married with children", `arrive to` wants at or in depending on the noun, `different than` is ordinary American English. Each rule is pinned in both directions, the guard case being the correct English next to the mistake. **The L1 rules are gated by pair, and the gating is what earns them their confidence** — *ter 30 anos* → "I am 30 years old" is a near-certainty for a Portuguese writer and only a guess for anyone else. The two zh rules the plan itself named are the ones this pack **refuses** to implement: dropped articles and he/she slips are not detectable from text alone ("She said he was late" is perfect whichever pronoun was meant), and flagging them would mean correcting correct writing. **The miscollocation list forced the session's one real design change.** It had to file as `collocation` rather than as its own family — same rail, same phrasing, and an accepted chunk plants in the garden exactly as the coach's would — but `type` had been quietly doubling as the answer to *which engine found this*, and that breaks the instant an offline rule proposes a collocation. Migration `0013_suggestion_source` splits the two apart: each pass now scopes its DELETE by engine, and the span tiebreak moved with it (an exact offline card beats an overlapping LLM one by source, not by type — an offline miscollocation is as exact as an offline comma). Without it the coach silently wiped every offline chunk on the page and the offline pass left the coach's rows to pile up; both directions are now tested, and a pre-0013 collocation row correctly backfills to the coach, since the offline list did not exist yet. **The daily invitation's whole substance is one stored date** — no count, no run of days, nothing that gets worse for being away, so a month away reads exactly like a day away; it lives in its own file because that is the property this feature would lose silently, and the test is named for it rather than for the query. Both answers spend the day's invitation, because being asked again after "not today" would make no a negotiation. **False friends are the one thing here that never becomes a card**: ~19 curated en↔pt entries, shown as a lavender block above the WordCard's definition and as at most one companion note per pass, with no `fix` anywhere — *actually* may well be the word she meant, and this is the mistake that makes a learner feel foolish rather than merely corrected. zh has none, which is the honest answer and not an unwritten one: the trap needs a shared script. Copy for the invitation and the false friends is greped by tests the same way the journal's is (*streak / in a row / 连续 / todos os dias*; *wrong / mistake / errado*) — the framing is the feature, and it is the part a future edit would undo while meaning well. Verified: go build/vet, `go test ./internal/...` clean, tsc, vite build, vitest 172/172 (30 new rule cases, 7 invitation, plus false-friend shape/tone guards), and a live throwaway DB on :8099 with **no LLM configured at all** — offline `did a mistake` → card → accept → garden card *made a mistake*, example bounded to its own corrected sentence, journal `kept:1`. ⚠️ **Not deployed and not seen in a browser**, and this one carries a migration, so it is a deploy rather than a rebuild. The pt-PT copy added here joins the pack a native speaker still has not reviewed.
|
||||
|
||||
Reference in New Issue
Block a user