// Package morph answers questions about Russian grammar from a dictionary. // // The second of the three mechanisms replacing hand-written Russian patterns // (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the sets that // can be finished; the embedder recognises an open set of phrasings; and this // package answers the questions that are about grammar rather than meaning: // // - is this word a form of a verb, so it carries its own subject? // - are these two tokens the same word in different cases? // // Three places used to answer those from a list of letter endings, and each list // was wrong in a way its own comment admitted. "канал" read as a past-tense verb // because it ends in -ал. A list of nineteen nouns ending in л existed only to // suppress the false positives of "ends in л means masculine past tense", which // is a pattern conceding it is wrong. Grammar is what a dictionary is for. // // Not the resident model. This has to be right every time, offline, in // microseconds, and a 1.7B is neither reliable enough nor fast enough to ask. // // The dictionary is github.com/aaaton/golem's Russian data, vendored. It is // embedded in the module, so a load failure is not a network problem and not a // config problem — it is corrupt data that got past the build. Every function // answers conservatively in that case rather than failing the turn, and says so // in its own doc comment. package morph import ( "log" "strings" "sync" "github.com/aaaton/golem/v4" "github.com/aaaton/golem/v4/dicts/ru" ) var ( once sync.Once lemma *golem.Lemmatizer loadErr error ) // dict loads the lemmatizer on first use. Loading costs a few megabytes of maps, // which is why it is not done at init: a daemon that never sees Russian never // pays for it. func dict() *golem.Lemmatizer { once.Do(func() { lemma, loadErr = golem.New(ru.New()) if loadErr != nil { // Once, not per call: this is a permanent condition and a voice loop // would otherwise fill the log with it at speech rate. log.Printf("morph: russian dictionary unavailable, answering conservatively: %v", loadErr) } }) return lemma } // Available reports whether the dictionary loaded. Callers do not need it to be // correct — every function below has a defined answer without it — but a test // that means to measure the dictionary should skip rather than pass vacuously. func Available() bool { dict() return loadErr == nil } // Lemma returns the dictionary form of a word, or the word itself when the // dictionary does not know it or could not load. An unknown word is its own // lemma: "бэкап" is not in the dictionary and there is nothing better to say // about it than what he said. func Lemma(word string) string { w := strings.ToLower(strings.TrimSpace(word)) if w == "" { return "" } l := dict() if l == nil { return w } if got := l.Lemma(w); got != "" { return got } return w } // infinitiveEndings — how a Russian infinitive ends. This is not a stem pattern: // it is applied to a LEMMA the dictionary returned, where the infinitive is the // dictionary form of every verb by definition, so the test is about the // dictionary's own output and not about the word he said. // // The reflexive forms are listed because a reflexive lemma keeps its particle: // "тренировался" lemmatises to "тренироваться", which ends in "ся" rather than // "ть". var infinitiveEndings = []string{"ться", "тись", "чься", "ть", "ти", "чь"} // IsVerbForm reports whether a word is some form of a verb — past tense, present, // imperative, reflexive, participle. A verb carries its own subject and tense, so // in Russian one verb is a whole sentence, which is what the callers care about. // // Without the dictionary this answers false: not knowing is not evidence that a // word IS a verb, and the callers all treat false as the cautious direction. func IsVerbForm(word string) bool { if dict() == nil { return false } l := Lemma(word) for _, e := range infinitiveEndings { if strings.HasSuffix(l, e) { return true } } return false } // SameWord reports whether two tokens are the same word in different cases — // "режим" and "режиме", "тихий" and "тихо". It is the test a stem-plus-endings // comparison was approximating, and it draws the line the ending list could not: // "тихонько" and "потихоньку" are different words, and the dictionary says so // because it has never heard of either. // // Without the dictionary this falls back to exact equality, which is the // narrowest honest answer. func SameWord(a, b string) bool { la, lb := Lemma(a), Lemma(b) if la == "" || lb == "" { return false } return la == lb }