Phase 21: Petal learns to be an English+Portuguese pair

The plan said "Hunspell pt-PT vendored like en-US". Measuring that first is
what saved it: nspell expands affixes eagerly on construction, and European
Portuguese's 1,340 rules over 44,257 stems want over a gigabyte of browser
heap — ~340 MB for the first 12,000 entries, and no return at all after three
minutes on the whole file. So the expansion runs once at build time instead:
1,039,058 forms, 2.66 MB gzipped, read by the same nspell in 842 ms.

The obvious npm package would also have shipped the wrong language. Both
dictionary-pt and dictionary-pt-br carry VERO, the Brazilian word list, so
vendoring by name puts pt-BR spellings behind a pt-PT label — the drift
SUGGESTIONS §3 warns about, arriving through the packaging where no reviewer
can see it. The source is Projecto Natura's, and the build script now asserts
the fault lines (receção in, recepção out) before writing anything.

Spellcheck consults both dictionaries and flags only what both reject, which
is the no-detector answer to a pair with no script boundary. The word card
does the same in the other direction: "data" is a word in both languages, so
Petal shows both readings rather than guessing which she meant.

Writing the tests caught the one real bug — extendedAlphabet was a snapshot
while correct/suggest read live, and her dictionary arrives after English, so
every lookup would have resolved "cora" while the underlines were already
right.

Not done, and not claimed: the pack has not been read by a pt-PT speaker, and
the Piper voice is deferred with the deploy.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 12:43:02 -07:00
parent 4de83d0da5
commit ccb43e5a4d
22 changed files with 1458 additions and 107 deletions
+69 -1
View File
@@ -174,9 +174,63 @@ func (p dreamProvider) Lookup(word string) (Result, error) {
}
res.Etymology = trimEtymology(ety)
if res.Reverse, err = p.reverse(norm); err != nil {
return Result{}, err
}
return res, nil
}
// reverse reads the token as a word of the writer's own language, and returns
// nil when it isn't one — which is the answer for almost every word she looks
// up, since she is writing English.
//
// The English de-inflection walk is deliberately *not* applied here. [candidates]
// knows about -s, -ed and -ing; running it over Portuguese would turn "vinhas"
// into "vinha" by an English rule that happens to be right and "cantava" into
// nothing by rules that are simply irrelevant. dict.db stores headwords, so an
// inflected Portuguese form finds nothing and the card shows only the English
// reading — the same outcome as today, rather than a confidently wrong one.
func (p dreamProvider) reverse(norm string) (*Reverse, error) {
back, err := p.dict.d.Equivalents(norm, p.native, langEN)
if err != nil {
return nil, err
}
defs, err := p.dict.d.Define(norm, p.native)
if err != nil {
return nil, err
}
if len(back) == 0 && len(defs) == 0 {
return nil, nil
}
rev := &Reverse{Lang: p.native}
if len(back) > maxGlossSenses {
back = back[:maxGlossSenses]
}
rev.Gloss = strings.Join(back, "; ")
for _, d := range defs {
rev.Definitions = append(rev.Definitions, Meaning{PartOfSpeech: d.POS, Definition: d.Gloss})
if len(rev.Definitions) >= maxReverseDefinitions {
break
}
}
prons, err := p.dict.d.Pronunciation(norm, p.native)
if err != nil {
return nil, err
}
rev.Phonetic = pickIPA(prons)
return rev, nil
}
// maxReverseDefinitions is smaller than [maxDefinitions]: the reverse reading is
// the second half of a card that already has an English one, and it is there to
// say "this is also a Portuguese word, and here is what it means" rather than to
// be a dictionary entry in its own right.
const maxReverseDefinitions = 2
// Gloss returns the writer's-language translation alone — the hover tooltip's
// fast path, one indexed query per candidate form and nothing else.
func (p dreamProvider) Gloss(word string) (GlossResult, error) {
@@ -188,7 +242,21 @@ func (p dreamProvider) Gloss(word string) (GlossResult, error) {
if err != nil {
return GlossResult{}, err
}
return GlossResult{Word: word, Gloss: gloss}, nil
res := GlossResult{Word: word, Gloss: gloss}
// The tooltip carries only the reverse *gloss*, not the whole reading: it is
// a one-line bubble under a resting pointer, and the popover is one click
// away for anyone who wants the rest.
back, err := p.dict.d.Equivalents(norm, p.native, langEN)
if err != nil {
return GlossResult{}, err
}
if len(back) > maxGlossSenses {
back = back[:maxGlossSenses]
}
res.Reverse = strings.Join(back, "; ")
return res, nil
}
// maxGlossSenses caps how many translations are strung together. One is often
+121
View File
@@ -96,6 +96,24 @@ func writeFixture(t *testing.T, seeded bool) string {
exec(`INSERT INTO word_synsets (word_id, synset_id, source) VALUES
(5, 1, 'wordnet'), (6, 1, 'omw'), (7, 1, 'omw')`)
// "data" is the collision the Latin pairs create and the zh pair never did:
// a real English word and a real Portuguese one, spelled identically and
// meaning different things. There is no honest way to look at it in a mixed
// document and know which was meant, so Petal shows both readings.
exec(`INSERT INTO words (id, word, lang, pos, frequency) VALUES
(8, 'data', 'en', 'noun', 800),
(9, 'data', 'pt-PT', 'noun', 700),
(10, 'date', 'en', 'noun', 750)`)
exec(`INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES
(8, 'noun', 'facts collected for reference', 'wordnet', 10),
(9, 'noun', 'dia do mês', 'wiktionary', 20),
(9, 'noun', 'momento no tempo', 'wiktionary', 30),
(9, 'noun', 'um terceiro sentido', 'wiktionary', 40)`)
exec(`INSERT INTO translations (word_id, translation, target_lang, source) VALUES
(9, 'date', 'en', 'kaikki')`)
exec(`INSERT INTO pronunciations (word_id, format, value, source) VALUES
(9, 'ipa', '/ˈdatɐ/', 'wiktionary')`)
exec(`INSERT INTO etymology (word_id, text, source) VALUES
(1, 'From Medieval Latin ephemerus, from Ancient Greek ἐφήμερος (ephḗmeros, "lasting only a day"), from ἐπί (epí, "upon") and ἡμέρα (hēméra, "day"). The sense of transience is attested in English from the late sixteenth century onwards.', 'wiktionary')`)
@@ -541,3 +559,106 @@ func TestContentsCountsRowsNotSupportedLanguages(t *testing.T) {
t.Error("Contents with no dictionary must still say something")
}
}
// The Latin+Latin wrinkle (SUGGESTIONS.md §3a). An English+Chinese pair never
// had to decide which language a word was in — the script decided. An
// English+Portuguese pair has no script boundary, and "data", "sale", "comum"
// and "tarde" are real words on both sides of it. Petal asks both directions
// and shows whatever answers, which needs no language detector and therefore
// cannot be wrong about somebody's writing.
func TestDreamLookupShowsBothReadingsOnACollision(t *testing.T) {
p := dreamProvider{dict: openFixture(t), native: "pt-PT"}
res, err := p.Lookup("data")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
// The English reading is unchanged and still leads.
if len(res.Definitions) == 0 || res.Definitions[0].Definition != "facts collected for reference" {
t.Fatalf("Definitions = %v, want the English sense first", res.Definitions)
}
if res.Reverse == nil {
t.Fatal("Reverse = nil; a word that exists in both languages must carry both readings")
}
if res.Reverse.Lang != "pt-PT" {
t.Errorf("Reverse.Lang = %q, want the writer's language", res.Reverse.Lang)
}
if res.Reverse.Gloss != "date" {
t.Errorf("Reverse.Gloss = %q, want the English meaning of the Portuguese word", res.Reverse.Gloss)
}
if res.Reverse.Phonetic != "ˈdatɐ" {
t.Errorf("Reverse.Phonetic = %q, want the Portuguese IPA without slashes", res.Reverse.Phonetic)
}
// The reverse reading is a footnote on a card that already has an English
// half, so it is capped harder than the main entry.
if len(res.Reverse.Definitions) != maxReverseDefinitions {
t.Fatalf("Reverse.Definitions = %d, want %d", len(res.Reverse.Definitions), maxReverseDefinitions)
}
if res.Reverse.Definitions[0].Definition != "dia do mês" {
t.Errorf("Reverse.Definitions[0] = %q, want the Portuguese sense",
res.Reverse.Definitions[0].Definition)
}
}
func TestDreamLookupHasNoReverseForAnEnglishOnlyWord(t *testing.T) {
// Which is almost every word she looks up: she is writing English. A
// second block under every card would make the collision case invisible.
p := dreamProvider{dict: openFixture(t), native: "pt-PT"}
res, err := p.Lookup("ephemeral")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if res.Reverse != nil {
t.Fatalf("Reverse = %+v, want none for a word that is only English", res.Reverse)
}
}
func TestDreamGlossCarriesTheReverseReading(t *testing.T) {
// The hover tooltip takes the same both-directions rule in one line less
// space: the reverse *gloss* only, never the definitions.
p := dreamProvider{dict: openFixture(t), native: "pt-PT"}
g, err := p.Gloss("data")
if err != nil {
t.Fatalf("Gloss: %v", err)
}
if g.Reverse != "date" {
t.Errorf("Gloss.Reverse = %q, want the English meaning of the Portuguese word", g.Reverse)
}
g, err = p.Gloss("ephemeral")
if err != nil {
t.Fatalf("Gloss: %v", err)
}
if g.Reverse != "" {
t.Errorf("Gloss.Reverse = %q, want none for an English-only word", g.Reverse)
}
if g.Gloss != "efémero; passageiro" {
t.Errorf("Gloss = %q, want the forward gloss untouched", g.Gloss)
}
}
func TestReverseIsSilentForTheEmbeddedProviders(t *testing.T) {
// The zh pair has no collisions and no DreamDict, and a writer with no
// dict.db at all falls through to `glossless`. Neither may start emitting a
// reverse block: the card would then claim a Chinese reading of an English
// word, which is worse than saying nothing.
set := NewSet(nil)
res, err := set.For(LangZh).Lookup("river")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if res.Reverse != nil {
t.Errorf("embedded Reverse = %+v, want none", res.Reverse)
}
res, err = set.For("pt-PT").Lookup("river")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if res.Reverse != nil {
t.Errorf("glossless Reverse = %+v, want none", res.Reverse)
}
}
+33
View File
@@ -47,6 +47,35 @@ type Result struct {
// Latin roots with English — "ephemeral" is much easier to keep once you
// have seen efémero next to it.
Etymology string `json:"etymology"`
// Reverse is the same token read as a word of the writer's own language,
// present only when it is one. Absent for every writer whose pair is not
// Latin-script, and for the overwhelming majority of words in one that is.
Reverse *Reverse `json:"reverse,omitempty"`
}
// Reverse is a lookup in the other direction: the token treated as a word of the
// writer's language, translated into English.
//
// It exists because a Latin-script pair has no script boundary to tell the two
// halves apart. In English+Chinese, "which language is this word?" answers
// itself. In English+Portuguese it does not: *sale*, *casa*, *comum*, *tarde*
// and *ali* are all real words on both sides, and *chat* and *pain* are the
// French versions of the same trap.
//
// Petal does not guess. It asks both directions and shows whatever comes back,
// which needs no detector, cannot be wrong about someone's writing, and — for a
// learner — is more interesting than a correct guess would have been.
type Reverse struct {
// Lang is the language this reading is in, so the card can label it.
Lang string `json:"lang"`
// Gloss is the English meaning of the native-language word.
Gloss string `json:"gloss"`
// Definitions are the word's senses as written in the writer's own
// language — the monolingual half, for when the English gloss isn't enough.
Definitions []Meaning `json:"definitions,omitempty"`
// Phonetic is IPA for the native-language pronunciation; "" when absent.
Phonetic string `json:"phonetic,omitempty"`
}
// unknownDifficulty is the [Result.Difficulty] value meaning "no score",
@@ -59,6 +88,10 @@ const unknownDifficulty = -1
type GlossResult struct {
Word string `json:"word"`
Gloss string `json:"gloss"`
// Reverse is the English meaning of the word read as one of the writer's
// own language — the tooltip's half of the both-directions rule (see
// [Reverse]). Empty unless the token is a word in her language too.
Reverse string `json:"reverse,omitempty"`
}
// maxSynonyms caps how many synonyms we hand the popover, even though the dataset