lexicon: positions are a closed class too (V-516)
"отметь второй пункт" and "закрепи вторым" name one position, so the ordinals belong in the data file beside the cardinals, with the gender and oblique forms Russian requires. Values are the 1-based position, and -1 is the last one, which is a position rather than a count. Ordinal and OrdinalIn are the Cardinal pair again, and for the same reason: a caller matching stems would also match "вторник". Ordinals() hands out the whole set sorted, for a caller that needs a case the file does not list and can ask the dictionary whether one of these is the same word. The genitive forms are also what a half-past hour needs (V-538), so this set is written for two callers.
This commit is contained in:
@@ -61,7 +61,7 @@ func mustLoad() lexiconFile {
|
||||
panic(fmt.Sprintf("lexicon: parse %s: %v", ruFile, err))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
|
||||
"interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals",
|
||||
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
||||
"not_place_after_v", "parts_of_day", "reminder_verbs",
|
||||
} {
|
||||
@@ -120,6 +120,58 @@ func Cardinal(word string) (int, bool) {
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// Ordinal reports the 1-based position a position word names, with -1 for the
|
||||
// last one. Same lookup shape as Cardinal, and the same reason: "второй" and
|
||||
// "вторым" are one position, and a caller matching stems would also match
|
||||
// "вторник".
|
||||
func Ordinal(word string) (int, bool) {
|
||||
n, ok := ru.Sets["ordinals"].Values[norm(word)]
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// Ordinals returns the position words with their positions, sorted, so a caller
|
||||
// that needs a form this set does not list can ask a morphological dictionary
|
||||
// whether one of these is the same word. Sorted because map order is not stable
|
||||
// and a caller folding these into a pattern would otherwise build a different one
|
||||
// every run.
|
||||
func Ordinals() []struct {
|
||||
Word string
|
||||
N int
|
||||
} {
|
||||
vals := ru.Sets["ordinals"].Values
|
||||
out := make([]struct {
|
||||
Word string
|
||||
N int
|
||||
}, 0, len(vals))
|
||||
for w, n := range vals {
|
||||
out = append(out, struct {
|
||||
Word string
|
||||
N int
|
||||
}{w, n})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Word < out[j].Word })
|
||||
return out
|
||||
}
|
||||
|
||||
// OrdinalIn reports the position word that comes FIRST in a sentence, so a
|
||||
// caller does not have to tokenize before asking. Word-boundary matched for the
|
||||
// reason above, and earliest-wins rather than first-found: map iteration order
|
||||
// would otherwise answer "отметь первый и второй" differently between runs.
|
||||
func OrdinalIn(text string) (int, bool) {
|
||||
lower := norm(text)
|
||||
best, at := 0, -1
|
||||
for w, n := range ru.Sets["ordinals"].Values {
|
||||
i := indexWord(lower, w)
|
||||
if i < 0 || (at >= 0 && i > at) {
|
||||
continue
|
||||
}
|
||||
// Two different words cannot match at one offset: both ends are
|
||||
// boundary-checked, so no key is a prefix of another as matched.
|
||||
best, at = n, i
|
||||
}
|
||||
return best, at >= 0
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -192,6 +244,28 @@ func abs(n int) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// indexWord is containsWord returning where the match starts, or -1.
|
||||
func indexWord(haystack, needle string) int {
|
||||
if needle == "" {
|
||||
return -1
|
||||
}
|
||||
from := 0
|
||||
for {
|
||||
i := strings.Index(haystack[from:], needle)
|
||||
if i < 0 {
|
||||
return -1
|
||||
}
|
||||
i += from
|
||||
if boundaryBefore(haystack, i) && boundaryAfter(haystack, i+len(needle)) {
|
||||
return i
|
||||
}
|
||||
from = i + len(needle)
|
||||
if from >= len(haystack) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -37,6 +37,22 @@
|
||||
"tell", "explain", "describe", "list"
|
||||
]
|
||||
},
|
||||
"ordinals": {
|
||||
"note": "Position words, as spoken, with the gender and oblique forms Russian requires: \"отметь второй пункт\" and \"закрепи вторым\" name one position (Vikunja #516). Values are the 1-based position, and -1 is the last one, which is a position rather than a count. The genitive forms here are also what a half-past hour needs (\"в половине восьмого\", V-538), so this set is written for two callers.",
|
||||
"values": {
|
||||
"первый": 1, "первая": 1, "первое": 1, "первого": 1, "первую": 1, "первым": 1, "первой": 1, "first": 1,
|
||||
"второй": 2, "вторая": 2, "второе": 2, "второго": 2, "вторую": 2, "вторым": 2, "second": 2,
|
||||
"третий": 3, "третья": 3, "третье": 3, "третьего": 3, "третью": 3, "третьим": 3, "третьей": 3, "third": 3,
|
||||
"четвёртый": 4, "четвертый": 4, "четвёртая": 4, "четвертая": 4, "четвёртого": 4, "четвертого": 4, "четвёртую": 4, "четвертую": 4, "четвёртым": 4, "четвертым": 4, "fourth": 4,
|
||||
"пятый": 5, "пятая": 5, "пятое": 5, "пятого": 5, "пятую": 5, "пятым": 5, "пятой": 5, "fifth": 5,
|
||||
"шестой": 6, "шестая": 6, "шестое": 6, "шестого": 6, "шестую": 6, "шестым": 6, "sixth": 6,
|
||||
"седьмой": 7, "седьмая": 7, "седьмое": 7, "седьмого": 7, "седьмую": 7, "седьмым": 7, "seventh": 7,
|
||||
"восьмой": 8, "восьмая": 8, "восьмое": 8, "восьмого": 8, "восьмую": 8, "восьмым": 8, "eighth": 8,
|
||||
"девятый": 9, "девятая": 9, "девятое": 9, "девятого": 9, "девятую": 9, "девятым": 9, "ninth": 9,
|
||||
"десятый": 10, "десятая": 10, "десятое": 10, "десятого": 10, "десятую": 10, "десятым": 10, "tenth": 10,
|
||||
"последний": -1, "последняя": -1, "последнее": -1, "последнего": -1, "последнюю": -1, "последним": -1, "last": -1
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"values": {
|
||||
|
||||
@@ -112,3 +112,51 @@ func TestCallerCannotEditTheLexicon(t *testing.T) {
|
||||
t.Fatal("the lexicon handed out its own backing array")
|
||||
}
|
||||
}
|
||||
|
||||
// The positions carry gender and oblique forms, because "второй пункт" and
|
||||
// "закрепи вторым" name one position (Vikunja #516). "last" is a position and not
|
||||
// a count, so it is -1 rather than a large number.
|
||||
func TestOrdinalsSpanGenderAndCase(t *testing.T) {
|
||||
for _, w := range []string{"второй", "вторая", "второе", "вторым", "второго", "second"} {
|
||||
n, ok := Ordinal(w)
|
||||
if !ok || n != 2 {
|
||||
t.Errorf("Ordinal(%q) = %d, %v; want 2, true", w, n, ok)
|
||||
}
|
||||
}
|
||||
for _, w := range []string{"последний", "последнюю", "last"} {
|
||||
if n, ok := Ordinal(w); !ok || n != -1 {
|
||||
t.Errorf("Ordinal(%q) = %d, %v; want -1, true", w, n, ok)
|
||||
}
|
||||
}
|
||||
// A weekday shares a stem with a position and is not one.
|
||||
if n, ok := Ordinal("вторник"); ok {
|
||||
t.Errorf("Ordinal(\"вторник\") = %d; a weekday is not a position", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Earliest wins, not map order: the same sentence must answer the same way twice.
|
||||
func TestOrdinalInTakesTheFirstPosition(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
n, ok := OrdinalIn("отметь первый и второй пункт")
|
||||
if !ok || n != 1 {
|
||||
t.Fatalf("run %d: OrdinalIn = %d, %v; want 1, true", i, n, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := OrdinalIn("отметь пункт"); ok {
|
||||
t.Error("a sentence with no position reported one")
|
||||
}
|
||||
}
|
||||
|
||||
// Ordinals is the escape hatch for the cases the file does not list, so it must
|
||||
// hand out every entry and hand out the same order twice.
|
||||
func TestOrdinalsListIsCompleteAndStable(t *testing.T) {
|
||||
a, b := Ordinals(), Ordinals()
|
||||
if len(a) != len(ru.Sets["ordinals"].Values) {
|
||||
t.Errorf("Ordinals returned %d of %d entries", len(a), len(ru.Sets["ordinals"].Values))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("Ordinals order is not stable at %d: %v vs %v", i, a[i], b[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user