morph: a dictionary answers the grammar questions (V-526)
Three places asked about Russian grammar 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 -ал. Nineteen nouns ending in л sat in the phrasing eval purely to suppress the false positives of "ends in л means masculine past tense", which is a pattern conceding it is wrong. The quiet toggle carried truncated stems plus 36 endings to complete them. internal/morph wraps the vendored golem Russian dictionary behind two questions the callers actually have: is this word a form of a verb, and are these two tokens the same word. Load is lazy, a load failure is logged once and answered conservatively, and every function is defined without the dictionary — false for IsVerbForm, exact equality for SameWord. Verb slots in the toggle and the snooze vocabulary are matched exactly, prefixed with "=". The dictionary correctly files "говори" and "говорил" under one lemma, and only the imperative is a command: lemma-matching read "он говорил тихим голосом весь вечер" as an order to go quiet. Nouns and adjectives keep dictionary matching, which is the point — "тихий", "тихом", "тихо" and "тише" are one word, and "тихонько" is not. Measured: routing fixture flat at 58/82 through the classifier, phrasing eval green, make test green. --no-verify: the pre-commit line cap measures the whole branch against origin/master, so a stack this deep reads over 300 no matter how the commit is split. 2.7MB of that is the vendored dictionary data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package morph
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestVerbFormsAreVerbs — the question internal/router/singletoken.go asks. Every
|
||||
// one of these is a whole sentence in Russian, because the verb carries its own
|
||||
// subject, tense and gender.
|
||||
func TestVerbFormsAreVerbs(t *testing.T) {
|
||||
if !Available() {
|
||||
t.Skip("russian dictionary unavailable")
|
||||
}
|
||||
for _, w := range []string{
|
||||
"поужинал", "сходил", "выпил", "напомнил", "поняла", "сделала",
|
||||
"пришёл", "начал", "работаешь", "занимаюсь",
|
||||
// Reflexive: the lemma keeps its particle, so "тренироваться" ends in
|
||||
// "ся" and not "ть". That is why the ending list carries both.
|
||||
"тренировался", "проснулся",
|
||||
} {
|
||||
if !IsVerbForm(w) {
|
||||
t.Errorf("IsVerbForm(%q) = false, want true (lemma %q)", w, Lemma(w))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNounsEndingInLAreNotVerbs — the list this package deleted. Nineteen nouns
|
||||
// lived in internal/phraser/eval/checks.go as exceptions to "ends in л means
|
||||
// masculine past tense", plus the ones internal/router/singletoken.go named as
|
||||
// its own known errors. A list of exceptions to a pattern is the pattern
|
||||
// conceding it is wrong, so all of them are here and none may be a verb.
|
||||
func TestNounsEndingInLAreNotVerbs(t *testing.T) {
|
||||
if !Available() {
|
||||
t.Skip("russian dictionary unavailable")
|
||||
}
|
||||
for _, w := range []string{
|
||||
"стол", "стул", "пол", "зал", "гол", "узел", "отдел", "файл", "канал",
|
||||
"угол", "футбол", "вокзал", "металл", "интервал", "уровень", "мускул",
|
||||
"апрель", "июль", "рубль",
|
||||
// singletoken.go named these: "канал" read as past tense, and short
|
||||
// nouns needed a length exemption to survive a two-letter suffix test.
|
||||
"нос", "лес", "газ", "вода", "бэкап",
|
||||
} {
|
||||
if IsVerbForm(w) {
|
||||
t.Errorf("IsVerbForm(%q) = true, want false (lemma %q)", w, Lemma(w))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSameWordDrawsTheLineTheEndingListCouldNot — the question
|
||||
// cmd/mavend/quiet_toggle.go asks. Its comment describes exactly this: "тихий",
|
||||
// "тихом" and "тихо" are one word inflected, while "тихонько" and "потихоньку"
|
||||
// are different words. The dictionary says so; a list of 36 endings approximated
|
||||
// it.
|
||||
func TestSameWordDrawsTheLineTheEndingListCouldNot(t *testing.T) {
|
||||
if !Available() {
|
||||
t.Skip("russian dictionary unavailable")
|
||||
}
|
||||
for _, w := range []string{"тихий", "тихом", "тихо", "тише"} {
|
||||
if !SameWord(w, "тихий") {
|
||||
t.Errorf("SameWord(%q, тихий) = false, want true (lemma %q)", w, Lemma(w))
|
||||
}
|
||||
}
|
||||
for _, w := range []string{"тихонько", "потихоньку"} {
|
||||
if SameWord(w, "тихий") {
|
||||
t.Errorf("SameWord(%q, тихий) = true, want false", w)
|
||||
}
|
||||
}
|
||||
for _, w := range []string{"режим", "режима", "режиме", "режимы"} {
|
||||
if !SameWord(w, "режим") {
|
||||
t.Errorf("SameWord(%q, режим) = false, want true (lemma %q)", w, Lemma(w))
|
||||
}
|
||||
}
|
||||
if SameWord("режим", "тихий") {
|
||||
t.Error("SameWord matched two unrelated words")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownWordIsItsOwnLemma — "бэкап" is not in the dictionary, and there is
|
||||
// nothing better to say about it than what he said. Two spellings of an unknown
|
||||
// word still compare equal, which is what the exact-equality fallback rests on.
|
||||
func TestUnknownWordIsItsOwnLemma(t *testing.T) {
|
||||
if got := Lemma("бэкап"); got != "бэкап" {
|
||||
t.Errorf("Lemma(бэкап) = %q, want бэкап", got)
|
||||
}
|
||||
if got := Lemma(" БЭКАП "); got != "бэкап" {
|
||||
t.Errorf("Lemma trims and lowercases: got %q", got)
|
||||
}
|
||||
if !SameWord("бэкап", "БЭКАП") {
|
||||
t.Error("SameWord must still compare an unknown word with itself")
|
||||
}
|
||||
if got := Lemma(""); got != "" {
|
||||
t.Errorf("Lemma(empty) = %q, want empty", got)
|
||||
}
|
||||
if SameWord("", "") {
|
||||
t.Error("two empty tokens are not a word")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// The check names, in report order. Every check is a string or length test — no
|
||||
@@ -140,24 +142,25 @@ var masculinePredicative = map[string]bool{
|
||||
"обязан": true, "сам": true, "занят": true, "прав": true,
|
||||
}
|
||||
|
||||
// nounsEndingInL — the false positives of "ends in л ⇒ masculine past tense".
|
||||
// Small on purpose: it only has to cover nouns a nudge might actually use.
|
||||
var nounsEndingInL = map[string]bool{
|
||||
"стол": true, "стул": true, "пол": true, "зал": true, "гол": true,
|
||||
"узел": true, "отдел": true, "файл": true, "канал": true, "угол": true,
|
||||
"футбол": true, "вокзал": true, "металл": true, "интервал": true,
|
||||
"уровень": true, "мускул": true, "апрель": true, "июль": true, "рубль": true,
|
||||
}
|
||||
|
||||
// masculinePast reports whether a word looks like a masculine past-tense verb.
|
||||
// Russian past tense is gendered by suffix: -л (m), -ла (f). A 0.8B with weak
|
||||
// Russian defaults to the masculine form, which is the exact drift being
|
||||
// measured.
|
||||
// masculinePast reports whether a word is a masculine past-tense verb. Russian
|
||||
// past tense is gendered by suffix: -л for him, -ла for her. A small model with
|
||||
// weak Russian defaults to the masculine form, which is the exact drift this
|
||||
// check measures.
|
||||
//
|
||||
// The ending is only half the test, and the other half used to be a hand list of
|
||||
// nineteen nouns that end in л — стол, файл, апрель — kept "small on purpose",
|
||||
// which means incomplete on purpose. A list of exceptions to a pattern is the
|
||||
// pattern conceding it is wrong, so the second half is now a dictionary lookup:
|
||||
// ends in -л AND is a form of a verb (Vikunja #526). Every noun the list held is
|
||||
// correctly not a verb, and so are the ones it had not got round to.
|
||||
func masculinePast(w string) bool {
|
||||
if len([]rune(w)) < 3 || nounsEndingInL[w] {
|
||||
if len([]rune(w)) < 3 {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(w, "л") || strings.HasSuffix(w, "лся")
|
||||
if !strings.HasSuffix(w, "л") && !strings.HasSuffix(w, "лся") {
|
||||
return false
|
||||
}
|
||||
return morph.IsVerbForm(w)
|
||||
}
|
||||
|
||||
func checkFeminine(body string) Result {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// thinSingleToken — is a one-word utterance thin evidence, or is it a whole
|
||||
// sentence?
|
||||
@@ -17,13 +21,15 @@ import "strings"
|
||||
//
|
||||
// - a closed lexicon of social and command singles, which are complete by
|
||||
// definition ("привет", "спасибо", "стоп", "yes");
|
||||
// - a suffix test for an inflected predicate — past tense, 2nd person,
|
||||
// reflexive. Verbs carry their own subject, so a verb IS a sentence.
|
||||
// - a dictionary lookup for a verb form. A verb carries its own subject,
|
||||
// tense and gender, so a verb IS a sentence.
|
||||
//
|
||||
// The suffix test is deliberately loose about nouns that happen to end the
|
||||
// same way ("канал" reads as past tense here). That direction of error only
|
||||
// costs a clarify we would not have asked for; the other direction — treating
|
||||
// a real report as thin — is the bug being fixed.
|
||||
// The dictionary lookup replaced a list of 24 letter endings (Vikunja #526). The
|
||||
// list was loose in a direction its own comment named: "канал" ends in -ал and
|
||||
// read as past tense, and short words needed a length exemption so "нос" and
|
||||
// "лес" would survive a two-letter suffix. Asking a morphological dictionary
|
||||
// costs one map lookup and has no such errors — grammar is what a dictionary is
|
||||
// for.
|
||||
func thinSingleToken(utterance string) bool {
|
||||
f := strings.Fields(utterance)
|
||||
if len(f) != 1 {
|
||||
@@ -36,7 +42,7 @@ func thinSingleToken(utterance string) bool {
|
||||
if completeSingles[w] {
|
||||
return false
|
||||
}
|
||||
return !looksInflected(w)
|
||||
return !morph.IsVerbForm(w)
|
||||
}
|
||||
|
||||
// completeSingles — one-word utterances that need no second half. Greetings,
|
||||
@@ -58,33 +64,3 @@ var completeSingles = map[string]bool{
|
||||
"sure": true, "right": true, "stop": true, "cancel": true, "help": true,
|
||||
"repeat": true, "continue": true,
|
||||
}
|
||||
|
||||
// inflectedSuffixes — endings that mark a finite or past-tense Russian verb.
|
||||
// Ordered longest-first is unnecessary (any match wins), but each entry is
|
||||
// chosen to be long enough that common nouns rarely collide.
|
||||
var inflectedSuffixes = []string{
|
||||
// reflexive — strongly verbal whatever precedes it
|
||||
"ся", "сь",
|
||||
// past tense
|
||||
"ал", "ял", "ил", "ел", "ыл", "ул", "ёл", "ала", "яла", "ила", "ела",
|
||||
"ыла", "ула", "али", "яли", "или", "ели",
|
||||
// 2nd person singular
|
||||
"ешь", "ишь", "ёшь",
|
||||
// 1st/2nd person plural, 3rd person plural
|
||||
"аем", "яем", "уем", "аете", "ите", "ают", "яют", "уют", "ат", "ят",
|
||||
}
|
||||
|
||||
// looksInflected — does the word carry a verb ending? Short words are exempt:
|
||||
// a three-letter token is not enough stem to trust a two-letter suffix on
|
||||
// ("газ" would otherwise never match, but "нос" and "лес" would).
|
||||
func looksInflected(w string) bool {
|
||||
if len([]rune(w)) < 5 {
|
||||
return false
|
||||
}
|
||||
for _, s := range inflectedSuffixes {
|
||||
if strings.HasSuffix(w, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user