Merge the lexicon, morph and pattern sweep (#250)

The next duplicated closed class is the weekdays, and it had four copies outside
lexicon_ru_v1.json. Each was short in a different direction: habit.go missed
средам and понедельником, calendar.go missed среде and воскресеньях, weatherq.go
missed среде and субботам. They fold into one WeekdayIndex, which reads
lexicon.Weekdays and asks morph.SameWord about the case. Every Russian weekday
form in all four lists lemmatises to the nominative the lexicon already holds.
English does not lemmatise, so the English weekdays went in as data with a note
saying why one side is grammar and the other is a list.

The fourth copy was a live bug. mentionsUnknownDay matched the stems сред,
пятниц, суббот and воскресен with strings.Contains, so среди, средство, средний
and среднем all read as Wednesday. A date question carrying any of them was
answered with про другие дни пока не скажу instead of the date. That is exactly
the hand-written Russian stem pattern the 2026-08-04 sweep removed, and it
survived because it is a string slice rather than a regexp.

weatherq.go held a third copy of three lexicon sets at once. It kept целом but
not общем, утром but not утра, среду but not среде, so those phrasings reached
the geocoder as city names. It keeps only the rooms of the house now, which are
genuinely its own.

Cardinals had a real gap. Five and up have one oblique form serving three cases,
so пяти was already whole. One to four decline separately and only the genitive
was listed, so к двум часам, к трём and к четырём all missed. Dative and
instrumental added for one to four.

The SameWord caller audit found no defect. Every caller that means the
imperative already matches exactly and says so.

(V-581)
This commit is contained in:
2026-08-06 03:34:28 +04:00
11 changed files with 251 additions and 60 deletions
+14 -9
View File
@@ -17,6 +17,7 @@ import (
"time" "time"
"github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/say" "github.com/kami/maven/internal/say"
) )
@@ -73,22 +74,26 @@ func mentionsUnknownPlace(u string) bool {
// date for a day she did not understand. // date for a day she did not understand.
const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу." const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу."
// dayWords — day references the calendar parser cannot resolve. A weekday name
// or a "через …" phrase means he asked about a specific other day.
var dayWords = []string{
"понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресен",
"через", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
}
// mentionsUnknownDay reports whether the question names a day the calendar // mentionsUnknownDay reports whether the question names a day the calendar
// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to // parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to
// pick an honest reply over a confidently wrong one. // pick an honest reply over a confidently wrong one.
// //
// Only called after ParseCalendarDate has already failed, so "завтра" and the // Only called after ParseCalendarDate has already failed, so "завтра" and the
// other words it does know never reach here. // other words it does know never reach here.
//
// The weekday half was a list of STEMS matched with strings.Contains until
// V-581 — "сред", "пятниц", "суббот". That is the hand-written Russian pattern
// the sweep of 2026-08-04 took out, and it was wrong in the way such a pattern
// always is: "среди", "средство" and "средний" all contain "сред", so a question
// carrying any of them was answered with onlyNearDaysReply instead of the date.
// Whole tokens now, and the weekday itself is router.WeekdayIndex, which reads
// the lexicon and asks the dictionary about the case.
func mentionsUnknownDay(u string) bool { func mentionsUnknownDay(u string) bool {
for _, w := range dayWords { for _, tok := range quietTokens(u) {
if strings.Contains(u, w) { if tok == "через" {
return true
}
if _, ok := router.WeekdayIndex(tok); ok {
return true return true
} }
} }
+35
View File
@@ -0,0 +1,35 @@
package main
import "testing"
// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The
// weekday half of this guard was a list of stems matched with strings.Contains,
// so "среди", "средство" and "средний" all read as Wednesday and the question
// was answered with onlyNearDaysReply instead of a date.
//
// The other half of the fix is coverage: a stem list stops at the forms whoever
// wrote it thought of, and "воскресеньях" was not one of them.
func TestMentionsUnknownDayReadsWordsNotStems(t *testing.T) {
for _, u := range []string{
"какое число в понедельник",
"какое число в среду",
"какое число в среде",
"что там по воскресеньям",
"what is the date on friday",
"какое число через неделю",
} {
if !mentionsUnknownDay(u) {
t.Errorf("mentionsUnknownDay(%q) = false, want true", u)
}
}
for _, u := range []string{
"какое число в среднем",
"сколько это в среднем",
"какое сегодня средство",
"какое число",
} {
if mentionsUnknownDay(u) {
t.Errorf("mentionsUnknownDay(%q) = true; it names no day", u)
}
}
}
+39 -9
View File
@@ -7,6 +7,10 @@ package main
import ( import (
"regexp" "regexp"
"strings" "strings"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
"github.com/kami/maven/internal/router"
) )
// isWeatherQuery returns true if the utterance is about weather. // isWeatherQuery returns true if the utterance is about weather.
@@ -27,16 +31,42 @@ func isWeatherQuery(u string) bool {
// come through whole and "в 5 утра" does not. // come through whole and "в 5 утра" does not.
var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`) var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`)
// weatherNonPlaces — words that follow "в" in a weather question and are not // weatherRooms — the rooms of the house, which are the only words in this
// cities. "какая погода в доме" is the smart-home sensor, not Open-Meteo, and // guard that belong to it. "какая погода в доме" is the smart-home sensor, not
// "тепло в комнате" is the same question about the same room. // Open-Meteo, and "тепло в комнате" is the same question about the same room.
var weatherNonPlaces = map[string]bool{ //
// The rest of the guard used to be a third copy of three closed sets that
// already exist in the lexicon: the weekdays, the parts of the day, and the
// words that follow "в" without naming a place (V-581). Each copy was short in
// its own direction — "среду" but not "среде", "утром" but not "утра", "целом"
// but not "общем" — so the same question phrased one word differently reached
// the geocoder as a city.
var weatherRooms = map[string]bool{
"доме": true, "квартире": true, "комнате": true, "спальне": true, "доме": true, "квартире": true, "комнате": true, "спальне": true,
"гостиной": true, "кухне": true, "гараже": true, "офисе": true, "гостиной": true, "кухне": true, "гараже": true, "офисе": true,
"выходные": true, "субботу": true, оскресенье": true, "понедельник": true, "обед": true, "обеде": true, ыходные": true, "выходных": true,
"вторник": true, "среду": true, "четверг": true, "пятницу": true, }
"обед": true, "обеде": true, "утро": true, "утром": true, "вечер": true,
"вечером": true, "ночь": true, "ночью": true, "целом": true, "принципе": true, // isWeatherNonPlace reports whether the word after "в" names something other
// than a place he could ask the weather for.
func isWeatherNonPlace(word string) bool {
if weatherRooms[word] {
return true
}
if _, ok := router.WeekdayIndex(word); ok {
return true
}
for _, w := range lexicon.PartsOfDay() {
if word == w || morph.SameWord(word, w) {
return true
}
}
for _, w := range lexicon.NotPlaceAfterV() {
if word == w {
return true
}
}
return false
} }
// extractWeatherLocation returns the place he named, or the configured default // extractWeatherLocation returns the place he named, or the configured default
@@ -63,7 +93,7 @@ func extractWeatherLocation(u, defaultLoc string) string {
} }
place := strings.TrimSpace(m[1]) place := strings.TrimSpace(m[1])
first := strings.ToLower(strings.Fields(place)[0]) first := strings.ToLower(strings.Fields(place)[0])
if weatherNonPlaces[first] { if isWeatherNonPlace(first) {
return defaultLoc return defaultLoc
} }
return place return place
+7
View File
@@ -29,6 +29,13 @@ func TestExtractWeatherLocation(t *testing.T) {
// the house sensors and the day words answer elsewhere. // the house sensors and the day words answer elsewhere.
{"тепло в комнате?", "Berlin", "Berlin"}, {"тепло в комнате?", "Berlin", "Berlin"},
{"какая погода в выходные", "Berlin", "Berlin"}, {"какая погода в выходные", "Berlin", "Berlin"},
// The cases the three private copies of the lexicon were short by
// (V-581): a weekday in a case the old map did not list, a part of the
// day in one it did not list, and "в общем".
{"какая погода в среде", "Berlin", "Berlin"},
{"какая погода в воскресеньях", "Berlin", "Berlin"},
{"какая погода в понедельникам", "Berlin", "Berlin"},
{"какая погода в общем", "Berlin", "Berlin"},
} }
for _, c := range cases { for _, c := range cases {
if got := extractWeatherLocation(c.utterance, c.def); got != c.want { if got := extractWeatherLocation(c.utterance, c.def); got != c.want {
+15 -1
View File
@@ -62,7 +62,7 @@ func mustLoad() lexiconFile {
} }
for _, name := range []string{ for _, name := range []string{
"interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals", "interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals",
"day_offsets", "weekdays", "months_genitive", "hours_spoken", "day_offsets", "weekdays", "weekdays_english", "months_genitive", "hours_spoken",
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour", "not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
"filler_particles", "task_done_words", "task_drop_words", "filler_particles", "task_done_words", "task_drop_words",
"confirm_yes", "confirm_no", "confirm_yes", "confirm_no",
@@ -283,6 +283,20 @@ func DayOffsetIn(text string) (int, bool) {
// Go's time.Weekday. An index off the end returns "". // Go's time.Weekday. An index off the end returns "".
func Weekday(i int) string { return at("weekdays", i) } func Weekday(i int) string { return at("weekdays", i) }
// Weekdays returns the seven Russian names in one slice, Sunday first, for a
// caller matching a token against all of them rather than rendering one. Only
// the nominative is here: every other case lemmatises to it, so an oblique form
// is morph's question and not a second list (V-581).
func Weekdays() []string { return words("weekdays") }
// WeekdayEnglish reports the Go time.Weekday index an English weekday names,
// singular or plural. English needs the list that Russian does not, because the
// vendored dictionary is Russian and leaves "mondays" as it found it.
func WeekdayEnglish(word string) (int, bool) {
n, ok := ru.Sets["weekdays_english"].Values[norm(word)]
return n, ok
}
// MonthGenitive returns the month name a date takes — "10 июля", not "июль". // MonthGenitive returns the month name a date takes — "10 июля", not "июль".
// The set is 1-indexed, so MonthGenitive(int(t.Month())) is the whole call. // The set is 1-indexed, so MonthGenitive(int(t.Month())) is the whole call.
func MonthGenitive(m int) string { return at("months_genitive", m) } func MonthGenitive(m int) string { return at("months_genitive", m) }
+17 -5
View File
@@ -56,13 +56,13 @@
} }
}, },
"cardinals": { "cardinals": {
"note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.", "note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed. From five up one oblique form serves the genitive, dative and prepositional, so \"пяти\" is the whole set; one to four decline separately and carry the dative and instrumental of their own, because \"к двум часам\" and \"к трём\" are hours he says (V-581).",
"values": { "values": {
"ноль": 0, "нуль": 0, "zero": 0, "ноль": 0, "нуль": 0, "zero": 0,
"один": 1, "одна": 1, "одно": 1, "одного": 1, "одной": 1, "одну": 1, "one": 1, "один": 1, "одна": 1, "одно": 1, "одного": 1, "одной": 1, "одну": 1, "одному": 1, "одним": 1, "one": 1,
"два": 2, "две": 2, "двух": 2, "two": 2, "два": 2, "две": 2, "двух": 2, "двум": 2, "двумя": 2, "two": 2,
"три": 3, "трёх": 3, "трех": 3, "three": 3, "три": 3, "трёх": 3, "трех": 3, "трём": 3, "трем": 3, "тремя": 3, "three": 3,
"четыре": 4, "четырёх": 4, "четырех": 4, "four": 4, "четыре": 4, "четырёх": 4, "четырех": 4, "четырём": 4, "четырем": 4, "четырьмя": 4, "four": 4,
"пять": 5, "пяти": 5, "five": 5, "пять": 5, "пяти": 5, "five": 5,
"шесть": 6, "шести": 6, "six": 6, "шесть": 6, "шести": 6, "six": 6,
"семь": 7, "семи": 7, "seven": 7, "семь": 7, "семи": 7, "seven": 7,
@@ -111,6 +111,18 @@
"четверг", "пятница", "суббота" "четверг", "пятница", "суббота"
] ]
}, },
"weekdays_english": {
"note": "The English weekday names with their Go time.Weekday index, plus the plural a habit is spoken in (\"on mondays\"). English is listed as words where Russian is not, because the vendored dictionary is Russian: it lemmatises \"пятницу\" to \"пятница\" on its own and leaves \"mondays\" alone (V-581). So the Russian side of a weekday match is grammar and the English side is data.",
"values": {
"sunday": 0, "sundays": 0,
"monday": 1, "mondays": 1,
"tuesday": 2, "tuesdays": 2,
"wednesday": 3, "wednesdays": 3,
"thursday": 4, "thursdays": 4,
"friday": 5, "fridays": 5,
"saturday": 6, "saturdays": 6
}
},
"months_genitive": { "months_genitive": {
"note": "The form a date takes: \"10 июля\", not \"июль\". 1-indexed, so slot 0 is empty and month numbers need no arithmetic.", "note": "The form a date takes: \"10 июля\", not \"июль\". 1-indexed, so slot 0 is empty and month numbers need no arithmetic.",
"words": [ "words": [
+40
View File
@@ -36,6 +36,46 @@ func TestClosedSetsAreComplete(t *testing.T) {
if _, ok := Cardinal("бэкап"); ok { if _, ok := Cardinal("бэкап"); ok {
t.Error("Cardinal must not answer for a word that is not a number") t.Error("Cardinal must not answer for a word that is not a number")
} }
// A spoken hour declines, and one to four decline further than the rest:
// "к двум часам" and "к трём" are hours, and only the dative says so (V-581).
for _, tc := range []struct {
word string
want int
}{
{"одному", 1}, {"двум", 2}, {"двумя", 2}, {"трём", 3}, {"трем", 3},
{"четырём", 4}, {"четырем", 4}, {"пяти", 5}, {"семи", 7},
} {
if got, ok := Cardinal(tc.word); !ok || got != tc.want {
t.Errorf("Cardinal(%q) = %d, %v; want %d, true", tc.word, got, ok, tc.want)
}
}
}
// TestWeekdaysAreOneList — the second copy of a closed class is the bug (V-581).
// Weekdays lived in four files outside this one, so the list is handed out whole
// and the English forms, which the Russian dictionary cannot lemmatise, are here.
func TestWeekdaysAreOneList(t *testing.T) {
days := Weekdays()
if len(days) != 7 || days[0] != "воскресенье" || days[1] != "понедельник" {
t.Fatalf("Weekdays() = %v; want the seven, Sunday first", days)
}
for i, name := range days {
if Weekday(i) != name {
t.Errorf("Weekdays()[%d] = %q, but Weekday(%d) = %q", i, name, i, Weekday(i))
}
}
for _, tc := range []struct {
word string
want int
}{{"sunday", 0}, {"monday", 1}, {"mondays", 1}, {"Friday", 5}, {"saturdays", 6}} {
if got, ok := WeekdayEnglish(tc.word); !ok || got != tc.want {
t.Errorf("WeekdayEnglish(%q) = %d, %v; want %d, true", tc.word, got, ok, tc.want)
}
}
if _, ok := WeekdayEnglish("понедельник"); ok {
t.Error("WeekdayEnglish answered for a Russian word; that side is morph's")
}
} }
// TestDayOffsetHasNoOrderingTrap — the defect a lookup removes. The callers this // TestDayOffsetHasNoOrderingTrap — the defect a lookup removes. The callers this
+8 -7
View File
@@ -35,16 +35,14 @@ var dayPlanWords = []string{
// answer today and stamp it with today's date, which is a wrong answer where // answer today and stamp it with today's date, which is a wrong answer where
// falling through is only a terse one. // falling through is only a terse one.
// //
// The weekday names are here as a refusal, not as a feature. "какие планы на // A weekday is a refusal too, and it is not in this list: IsDayPlanQuery asks
// понедельник?" carries no other-day token in the сегодня family and does carry // WeekdayIndex, so every case of every name refuses rather than the nine forms
// "планы", so the plan used to claim it and recite today. // that used to be written out here (V-581). "какие планы на понедельник?"
// carries no other-day token in the сегодня family and does carry "планы", so
// the plan used to claim it and recite today.
var otherDayWords = []string{ var otherDayWords = []string{
"завтра", "послезавтра", "вчера", "позавчера", "завтра", "послезавтра", "вчера", "позавчера",
"tomorrow", "yesterday", "tomorrow", "yesterday",
"понедельник", "вторник", "среду", "среда", "четверг", "пятницу", "пятница",
"субботу", "суббота", "воскресенье",
"понедельника", "вторника", "четверга", "пятницы", "субботы", "воскресенья",
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
"неделю", "неделя", "недели", "неделе", "неделю", "неделя", "недели", "неделе",
"выходные", "выходных", "выходным", "выходные", "выходных", "выходным",
"месяц", "месяца", "месяце", "месяц", "месяца", "месяце",
@@ -69,6 +67,9 @@ func IsDayPlanQuery(text string) bool {
} }
toks := planTokens(text) toks := planTokens(text)
for _, t := range toks { for _, t := range toks {
if _, ok := WeekdayIndex(t); ok {
return false
}
for _, w := range otherDayWords { for _, w := range otherDayWords {
if t == w { if t == w {
return false return false
+5 -21
View File
@@ -27,26 +27,10 @@ var habitMarkers = []string{
"typically", "normally", "typically", "normally",
} }
// weekdayWords — every form of a weekday name maven needs to recognise, // The weekday a habit question names comes from WeekdayIndex, not from a map
// including the "по …ам" plural the question is usually phrased in. // here. This file used to keep its own declension table, which had "воскресеньях"
var weekdayWords = map[string]time.Weekday{ // and no "средах" — a list of forms is finished by whoever last thought of one,
"понедельник": time.Monday, "понедельникам": time.Monday, // and a dictionary is not (V-581).
"вторник": time.Tuesday, "вторникам": time.Tuesday,
"среда": time.Wednesday, "среду": time.Wednesday, "средам": time.Wednesday,
"четверг": time.Thursday, "четвергам": time.Thursday,
"пятница": time.Friday, "пятницу": time.Friday, "пятницам": time.Friday,
"суббота": time.Saturday, "субботу": time.Saturday, "субботам": time.Saturday,
"воскресенье": time.Sunday, "воскресеньям": time.Sunday,
"воскресенья": time.Sunday, "воскресенью": time.Sunday,
"воскресеньем": time.Sunday, "воскресеньях": time.Sunday,
"monday": time.Monday, "mondays": time.Monday,
"tuesday": time.Tuesday, "tuesdays": time.Tuesday,
"wednesday": time.Wednesday, "wednesdays": time.Wednesday,
"thursday": time.Thursday, "thursdays": time.Thursday,
"friday": time.Friday, "fridays": time.Friday,
"saturday": time.Saturday, "saturdays": time.Saturday,
"sunday": time.Sunday, "sundays": time.Sunday,
}
// weekendWords — the weekend as one unit. "что я обычно делаю по выходным?" // weekendWords — the weekend as one unit. "что я обычно делаю по выходным?"
// has a habit marker and names days, but no weekday name is in it, so it used // has a habit marker and names days, but no weekday name is in it, so it used
@@ -77,7 +61,7 @@ func ParseHabitQuery(text string) (HabitQuery, bool) {
return HabitQuery{}, false return HabitQuery{}, false
} }
for _, t := range toks { for _, t := range toks {
if wd, ok := weekdayWords[t]; ok { if wd, ok := WeekdayIndex(t); ok {
return HabitQuery{Weekday: wd, HasWeekday: true}, true return HabitQuery{Weekday: wd, HasWeekday: true}, true
} }
if weekendWords[t] { if weekendWords[t] {
+27 -8
View File
@@ -3,6 +3,7 @@ package router
import ( import (
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph" "github.com/kami/maven/internal/morph"
@@ -234,16 +235,34 @@ func isMonth(tok string) bool {
return false return false
} }
// isWeekday reports whether the token is a day of the week in any case. The // WeekdayIndex reports which day of the week a token names, in any case and in
// lexicon lists the nominative, and "в пятницу" is what a reminder says, so the // either language, or false when it names none.
// match is by lemma — grammar is morph's job, not a second word list. //
func isWeekday(tok string) bool { // One matcher for the whole daemon (V-581). Four files used to keep a weekday
for i := 0; i < 7; i++ { // list of their own and each one was short in a different direction: the habit
if morph.SameWord(tok, lexicon.Weekday(i)) { // map had "воскресеньях" but no "средах", the plan refusal had "среду" but not
return true // "среде", and cmd/mavend matched the STEM "сред" with strings.Contains, so
// "среди" and "средство" read as Wednesday. The lexicon lists the nominative,
// every Russian case lemmatises to it, and only English needs its forms written
// out — the vendored dictionary is Russian and leaves "mondays" alone.
func WeekdayIndex(tok string) (time.Weekday, bool) {
t := strings.ToLower(strings.TrimSpace(tok))
if n, ok := lexicon.WeekdayEnglish(t); ok {
return time.Weekday(n), true
}
for i, name := range lexicon.Weekdays() {
if morph.SameWord(t, name) {
return time.Weekday(i), true
} }
} }
return false return 0, false
}
// isWeekday reports whether the token is a day of the week, when the caller
// does not need to know which one.
func isWeekday(tok string) bool {
_, ok := WeekdayIndex(tok)
return ok
} }
// timeMarkers — the words that name a time on their own: the qualifiers that // timeMarkers — the words that name a time on their own: the qualifiers that
+44
View File
@@ -0,0 +1,44 @@
package router
import (
"testing"
"time"
)
// TestWeekdayIndexReplacesFourLists — four files kept a weekday list of their
// own and each was short in a different direction (V-581). The forms below are
// the ones at least one of those lists missed, so they are the point of having
// one matcher: the lexicon names the day and the dictionary answers the case.
func TestWeekdayIndexReplacesFourLists(t *testing.T) {
for _, tc := range []struct {
word string
want time.Weekday
}{
{"понедельник", time.Monday},
{"понедельникам", time.Monday},
{"понедельником", time.Monday},
{"вторник", time.Tuesday},
{"среда", time.Wednesday},
{"среду", time.Wednesday},
{"среде", time.Wednesday},
{"средам", time.Wednesday},
{"четверга", time.Thursday},
{"пятницу", time.Friday},
{"субботам", time.Saturday},
{"воскресеньях", time.Sunday},
{"Воскресенье", time.Sunday},
{"monday", time.Monday},
{"Fridays", time.Friday},
} {
got, ok := WeekdayIndex(tc.word)
if !ok || got != tc.want {
t.Errorf("WeekdayIndex(%q) = %v, %v; want %v, true", tc.word, got, ok, tc.want)
}
}
// A stem match said yes to all of these. A word match says no.
for _, w := range []string{"среди", "средство", "средний", "среднем", "субботник", "", "через"} {
if _, ok := WeekdayIndex(w); ok {
t.Errorf("WeekdayIndex(%q) claimed a weekday", w)
}
}
}