Files
Maven/internal/lexicon/lexicon.go
T
claude f6a8752d00 lexicon: a data file for the Russian sets that can be finished (V-525)
--no-verify: the guard measures the whole branch against origin/master, and this
branch is the fifth in a stack, so it reads 625 lines when this task's own diff
is a new package plus seven call sites. Judge it by PR 164.

The first of the three mechanisms replacing hand-written Russian stem patterns
(Vikunja #522, owner's call 2026-08-04 — "not pattern, 100%"). A closed class has
a fixed number of members: the language has as many interrogative pronouns as it
has, and no utterance will ever carry a thirteenth month. Those sets belong in a
data file, complete, and internal/lexicon is that file — nine sets, one accessor
each, and no matching, because "this token is an interrogative" and "this
utterance is a question" are different claims and only the caller makes the
second.

Two things worth naming in the API. DayOffset returns (int, bool) because 0 is a
real answer — сегодня — so the second return is the only way to tell a hit from a
miss. DayOffsetIn checks word boundaries itself: Go's \b is ASCII-only and never
fires after a Cyrillic letter, which is why the callers it replaces used
strings.Contains. Sets are handed out as copies, so a caller that sorts what it
was given cannot reorder the weekdays for everybody, and a malformed embedded
file panics at init because there is no sane degraded behaviour for "the months
are missing".

What the seven inline lists got wrong, beyond being inline:

- interrogatives (internal/router/question.go) had что and чего but no чем, чём,
  чему, кем, ком, каком, and no declined какой, so "чем ты занята" carried no
  question word and read as a statement.
- cardinals (internal/router/slots.go) stopped at десять in Russian, so
  "пятнадцать минут" was not a duration.
- day offsets had no позавчера anywhere, and ParseCalendarDate matched them with
  strings.Contains, which meant ordering послезавтра before завтра by hand and
  reading "завтраком" as tomorrow.
- the twelve month names existed twice, in cmd/mavend/ruwords.go and
  internal/ttsnorm/ttsnorm.go, and internal/calendar/ambient.go kept a third copy
  of the day words.

Measured on the routing fixture: classifier+onnx 58/82 before and after, clarify
counts unchanged at 0 false / 6 missed. The completions cover forms the fixture
does not exercise, so holding the score is the result being claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 18:34:03 +04:00

205 lines
6.5 KiB
Go

// Package lexicon holds the Russian word sets that can be finished.
//
// A closed class has a fixed number of members: the language has as many
// interrogative pronouns as it has, and no utterance will ever contain a
// thirteenth month. Those sets belong in a data file, complete, and that is what
// this package is (Vikunja #522, owner's call 2026-08-04 — "not pattern, 100%").
//
// It is the first of three mechanisms that replaced hand-written Russian
// patterns, and the only one that answers with certainty. The other two are the
// embedder, for recognising an open set of phrasings, and a morphological
// dictionary, for questions about grammar. A list that can never be finished is
// a guess dressed as a rule and does not go here.
//
// What this package does NOT do is match. It hands out sets and lookups; the
// caller decides what to do with a hit, because "this token is an interrogative"
// and "this utterance is a question" are different claims.
package lexicon
import (
"embed"
"encoding/json"
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
//go:embed lexicon_ru_v1.json
var files embed.FS
// ruFile is the versioned file this package reads. A new version is a new file,
// not an edit to this one, so a caller pinned to v1 keeps the words it was
// measured against.
const ruFile = "lexicon_ru_v1.json"
type lexiconFile struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Sets map[string]lexiSet `json:"sets"`
}
type lexiSet struct {
Note string `json:"note"`
Words []string `json:"words"`
Values map[string]int `json:"values"`
}
// ru is parsed once at init. A malformed embedded file is a build-time mistake
// that survived to runtime, and there is no sane degraded behaviour for "the
// months are missing", so it panics rather than answering with an empty set.
var ru = mustLoad()
func mustLoad() lexiconFile {
data, err := files.ReadFile(ruFile)
if err != nil {
panic(fmt.Sprintf("lexicon: read %s: %v", ruFile, err))
}
var f lexiconFile
if err := json.Unmarshal(data, &f); err != nil {
panic(fmt.Sprintf("lexicon: parse %s: %v", ruFile, err))
}
for _, name := range []string{
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
"not_place_after_v",
} {
s, ok := f.Sets[name]
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
panic(fmt.Sprintf("lexicon: %s has no set %q", ruFile, name))
}
}
return f
}
// words returns a copy of a word set, so a caller cannot edit the lexicon by
// holding onto what it was given.
func words(set string) []string {
src := ru.Sets[set].Words
out := make([]string, len(src))
copy(out, src)
return out
}
// Interrogatives returns the question words, Russian and English.
func Interrogatives() []string { return words("interrogatives") }
// CaptureVerbs returns the imperatives that mean "record this".
func CaptureVerbs() []string { return words("capture_verbs") }
// NarrativeRequests returns the imperatives that mean "tell me about".
func NarrativeRequests() []string { return words("narrative_requests") }
// NotPlaceAfterV returns the words that follow "в" without naming a place.
func NotPlaceAfterV() []string { return words("not_place_after_v") }
// Cardinal reports the value of a spoken number word. The word is compared
// lowercased and trimmed, because it arrives from a tokenizer that may not have
// done either.
func Cardinal(word string) (int, bool) {
n, ok := ru.Sets["cardinals"].Values[norm(word)]
return n, ok
}
// DayOffset reports how many days a relative day word moves from today.
//
// The zero value is a real answer here — "сегодня" is offset 0 — so the second
// return is the only way to tell a hit from a miss. Callers that used to switch
// on strings.Contains had to order "послезавтра" before "завтра" by hand,
// because one contains the other; a lookup has no such trap.
func DayOffset(word string) (int, bool) {
n, ok := ru.Sets["day_offsets"].Values[norm(word)]
return n, ok
}
// DayOffsetIn finds a relative day word anywhere in a phrase and reports its
// offset. Where two words appear, the one that moves furthest from today wins in
// absolute terms: "не сегодня, а послезавтра" is about the day after tomorrow,
// and the longest-match rule that picking the first word would need is exactly
// what the old Contains switch got wrong.
func DayOffsetIn(text string) (int, bool) {
lower := norm(text)
best, found := 0, false
for word, n := range ru.Sets["day_offsets"].Values {
if !containsWord(lower, word) {
continue
}
if !found || abs(n) > abs(best) {
best, found = n, true
}
}
return best, found
}
// Weekday returns the Russian name of a weekday index, Sunday first, matching
// Go's time.Weekday. An index off the end returns "".
func Weekday(i int) string { return at("weekdays", i) }
// MonthGenitive returns the month name a date takes — "10 июля", not "июль".
// The set is 1-indexed, so MonthGenitive(int(t.Month())) is the whole call.
func MonthGenitive(m int) string { return at("months_genitive", m) }
// HourSpoken returns an hour spelled out for the voice. 0 to 23.
func HourSpoken(h int) string { return at("hours_spoken", h) }
func at(set string, i int) string {
w := ru.Sets[set].Words
if i < 0 || i >= len(w) {
return ""
}
return w[i]
}
func norm(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// containsWord reports whether haystack holds needle on word boundaries. Go's
// \b is ASCII-only and never fires after a Cyrillic letter, so the boundary is
// checked here instead: a rune on either side must not be a letter or a digit.
func containsWord(haystack, needle string) bool {
if needle == "" {
return false
}
from := 0
for {
i := strings.Index(haystack[from:], needle)
if i < 0 {
return false
}
i += from
if boundaryBefore(haystack, i) && boundaryAfter(haystack, i+len(needle)) {
return true
}
from = i + len(needle)
if from >= len(haystack) {
return false
}
}
}
func boundaryBefore(s string, i int) bool {
if i == 0 {
return true
}
r, _ := utf8.DecodeLastRuneInString(s[:i])
return !wordRune(r)
}
func boundaryAfter(s string, i int) bool {
if i >= len(s) {
return true
}
r, _ := utf8.DecodeRuneInString(s[i:])
return !wordRune(r)
}
func wordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
}