half-past hours parse as the hour being entered (V-538)

Russian names a half hour by the hour it is entering, in the genitive, so
"половина восьмого" is 07:30 and never 08:30. Neither date parser read that
shape, so the reminder parsed to nothing.

rewriteHalfPast runs in front of the token pass in SpellOutDigits, so the
python parser and the stub both see "в 7:30". It also reads the contracted
"полвосьмого" and the quarter-to shape "без четверти восемь", which counts
from a cardinal and is 07:45. Minus one is in one place, clockHourBefore, with
twelve rather than zero before one.

Ordinals eleven and twelve added to the lexicon, because a clock reaches them.
Minutes a spoken clock does not use are left alone: a guess here is a missed
dose.

Classifier + onnx over the routing fixture 58/82 to 62/87, three new cases,
none regressed. Python dateparser is not installed on this host, so only the
stub was measured. See docs/evals/2026-08-05-half-past-hours.md.
This commit is contained in:
2026-08-05 14:24:49 +04:00
parent f8947bef5a
commit 8bbdcd2727
7 changed files with 306 additions and 2 deletions
+43
View File
@@ -0,0 +1,43 @@
# Half-past and quarter-to hours, 2026-08-05
Vikunja V-538. `rewriteHalfPast` in `internal/router/halfpast.go`, run in front of
the token pass inside `SpellOutDigits`, so both date parsers see digits.
## What the shapes are
Russian names a half hour by the hour being ENTERED, in the genitive. "половина
восьмого" is 07:30. "без четверти восемь" counts the other way, from a cardinal,
and is 07:45. Both are minus one from the word in the sentence, and the arithmetic
lives in one function, `clockHourBefore`.
## Result
| | before | after |
|---|---|---|
| classifier + onnx over the routing fixture | 58/82 (70.7%) | 62/87 (71.3%) |
| new fixture cases passing | — | 2 of 3 |
| stub parser reads a half hour | no | yes |
The three new cases are ru-rem-008, ru-rem-009 and ru-rem-010. No existing case
regressed and no new clarify appeared.
ru-rem-009, "разбуди меня полвосьмого", still misses the intent. Its two siblings
without a half hour miss it the same way. ru-rem-005 "разбуди меня в 6:30" routes
to `fact`, and en-rem-002 "wake me at 6:15" does too. So the miss is the "разбуди"
phrasing against the classifier, not the half hour. The time slot now fills.
## Not measured here
Python dateparser. It is not installed on this host, so only the stub was run.
The rewrite emits "в 7:30 вечера". The script's own qualifier rewrite turns that
trailing "вечера" into "pm", which is the shape it already reads for a whole hour.
Judge it on the box.
The LLM arm. No llama-server in this run, so the cascade number is the classifier
floor.
## Left out on purpose
Minutes a spoken clock does not use. "без семи восемь" is not rewritten, because
nobody says it and a guess in this shape is a missed dose. The parsers fail on it
as they did before.
+3 -1
View File
@@ -38,7 +38,7 @@
]
},
"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.",
"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. It runs to twelve rather than ten because a clock does: \"в половине двенадцатого\" is 11:30.",
"values": {
"первый": 1, "первая": 1, "первое": 1, "первого": 1, "первую": 1, "первым": 1, "первой": 1, "first": 1,
"второй": 2, "вторая": 2, "второе": 2, "второго": 2, "вторую": 2, "вторым": 2, "second": 2,
@@ -50,6 +50,8 @@
"восьмой": 8, "восьмая": 8, "восьмое": 8, "восьмого": 8, "восьмую": 8, "восьмым": 8, "eighth": 8,
"девятый": 9, "девятая": 9, "девятое": 9, "девятого": 9, "девятую": 9, "девятым": 9, "ninth": 9,
"десятый": 10, "десятая": 10, "десятое": 10, "десятого": 10, "десятую": 10, "десятым": 10, "tenth": 10,
"одиннадцатый": 11, "одиннадцатая": 11, "одиннадцатое": 11, "одиннадцатого": 11, "одиннадцатую": 11, "одиннадцатым": 11, "eleventh": 11,
"двенадцатый": 12, "двенадцатая": 12, "двенадцатое": 12, "двенадцатого": 12, "двенадцатую": 12, "двенадцатым": 12, "twelfth": 12,
"последний": -1, "последняя": -1, "последнее": -1, "последнего": -1, "последнюю": -1, "последним": -1, "last": -1
}
},
+3
View File
@@ -58,6 +58,9 @@
{ "id": "ru-rem-005", "utterance": "разбуди меня в 6:30", "lang": "ru", "intent": "reminder", "want_time": true, "tags": ["hard"], "note": "wake-me phrasing, no напомни stem" },
{ "id": "ru-rem-006", "utterance": "напомни послезавтра в 12 забрать заказ", "lang": "ru", "intent": "reminder", "want_time": true },
{ "id": "ru-rem-007", "utterance": "через два часа напомни проверить бэкап", "lang": "ru", "intent": "reminder", "want_time": true, "tags": ["inverted"] },
{ "id": "ru-rem-008", "utterance": "напомни в половине восьмого выпить таблетку", "lang": "ru", "intent": "reminder", "want_time": true, "tags": ["hard"], "note": "half past, spoken as the ordinal of the hour being entered: 07:30, not 08:30 (V-538)" },
{ "id": "ru-rem-009", "utterance": "разбуди меня полвосьмого", "lang": "ru", "intent": "reminder", "want_time": true, "tags": ["hard"], "note": "the contracted half hour, and no preposition to hang the clock on" },
{ "id": "ru-rem-010", "utterance": "напомни без четверти восемь выходить", "lang": "ru", "intent": "reminder", "want_time": true, "tags": ["hard"], "note": "quarter to eight is 07:45, counted from a cardinal" },
{ "id": "en-rem-001", "utterance": "remind me in 45 minutes to stretch", "lang": "en", "intent": "reminder", "want_time": true },
{ "id": "en-rem-002", "utterance": "wake me at 6:15", "lang": "en", "intent": "reminder", "want_time": true, "tags": ["hard"] },
+176
View File
@@ -0,0 +1,176 @@
package router
import (
"fmt"
"strings"
"github.com/kami/maven/internal/lexicon"
)
// Half-past and quarter-to, rewritten to digits before either date parser sees
// the sentence.
//
// Russian says a half hour as the ordinal of the hour being ENTERED, in the
// genitive: "половина восьмого" is 07:30, not 08:30. Neither python dateparser
// nor the stub reads that shape, so "напомни в половине восьмого" parsed to
// nothing (V-538). Off by one in the wrong direction is a missed dose, so the
// rewrite is the only place the arithmetic lives, and it is minus one every
// time.
//
// "без четверти восемь" counts the other way — it is 07:45, the hour NOT yet
// reached, spoken as a cardinal. Same minus one, different minutes.
//
// Both shapes come out as "в H:MM", with the preposition kept when the sentence
// had one and added when it did not, because the stub scans for "в" before a
// clock and the contracted "полвосьмого" carries no preposition at all.
// halfWords — the forms of "половина" a spoken time uses. "в половине",
// "половина", "к половине", "полвосьмого". Closed and tiny; the ordinal beside
// them is what carries the hour, and that comes from the lexicon.
var halfWords = map[string]bool{
"половина": true, "половине": true, "половину": true, "половины": true,
"пол": true, "half": true,
}
// minutesTo — the words that name the minutes in a "без X" hour. "четверти" is
// the only one that is not a number; the rest are cardinals and are read as
// cardinals.
var minutesTo = map[string]int{"четверти": 15, "quarter": 15}
// rewriteHalfPast turns a spoken half hour or quarter-to hour into digits.
// Returns text unchanged when it holds neither shape.
func rewriteHalfPast(text string) string {
toks := strings.Fields(text)
if len(toks) < 2 {
return text
}
out := make([]string, 0, len(toks))
for i := 0; i < len(toks); i++ {
if n, width, ok := halfPastAt(toks, i); ok {
out = appendClock(out, n, 30)
i += width - 1
continue
}
if h, m, width, ok := quarterToAt(toks, i); ok {
out = appendClock(out, h, m)
i += width - 1
continue
}
out = append(out, toks[i])
}
return strings.Join(out, " ")
}
// halfPastAt reads a half hour starting at i, returning the hour it names and
// how many tokens it spans.
func halfPastAt(toks []string, i int) (hour, width int, ok bool) {
head := cleanWord(toks[i])
// The contracted one word: "полвосьмого", "пол-восьмого".
for _, prefix := range []string{"пол-", "пол"} {
if !strings.HasPrefix(head, prefix) {
continue
}
if h, ok := enteredHour(strings.TrimPrefix(head, prefix)); ok {
return h, 1, true
}
}
if !halfWords[head] || i+1 >= len(toks) {
return 0, 0, false
}
h, ok := enteredHour(cleanWord(toks[i+1]))
if !ok {
return 0, 0, false
}
return h, 2, true
}
// quarterToAt reads a "без четверти восемь" / "без двадцати восемь" hour
// starting at i.
func quarterToAt(toks []string, i int) (hour, minute, width int, ok bool) {
if cleanWord(toks[i]) != "без" || i+2 >= len(toks) {
return 0, 0, 0, false
}
to, ok := minutesBefore(cleanWord(toks[i+1]))
if !ok {
return 0, 0, 0, false
}
h, ok := comingHour(cleanWord(toks[i+2]))
if !ok {
return 0, 0, 0, false
}
return h, 60 - to, 3, true
}
// minutesBefore reads the minutes still to run in a "без X" hour. A cardinal is
// only minutes when it divides the hour the way a spoken clock does, so "без
// семи восемь" is not a time anyone says and is left alone.
func minutesBefore(word string) (int, bool) {
if m, ok := minutesTo[word]; ok {
return m, true
}
n, ok := lexicon.Cardinal(word)
if !ok {
return 0, false
}
switch n {
case 5, 10, 15, 20, 25:
return n, true
}
return 0, false
}
// enteredHour maps the ordinal of the hour being entered to the hour on the
// clock: "восьмого" is the eighth hour, which is 07:xx. "первого" is 12:xx,
// because the hour before one is twelve and not zero.
func enteredHour(word string) (int, bool) {
n, ok := lexicon.Ordinal(word)
if !ok || n < 1 || n > 12 {
return 0, false
}
return clockHourBefore(n), true
}
// comingHour maps the cardinal of the hour not yet reached, which counts the
// same way: "без четверти восемь" is 07:45.
func comingHour(word string) (int, bool) {
d, ok := numeralDigit(word)
if !ok {
return 0, false
}
var n int
if _, err := fmt.Sscanf(d, "%d", &n); err != nil || n < 1 || n > 12 {
return 0, false
}
return clockHourBefore(n), true
}
// clockHourBefore is the whole of the arithmetic: the hour before n, with
// twelve rather than zero before one.
func clockHourBefore(n int) int {
if n == 1 {
return 12
}
return n - 1
}
// appendClock writes the clock, keeping a preposition the sentence already had
// and adding "в" when it had none. The stub parser scans for "в" before a
// clock, and the contracted form carries no preposition.
func appendClock(out []string, hour, minute int) []string {
if n := len(out); n == 0 || !clockPrepositions[cleanWord(out[n-1])] {
out = append(out, "в")
}
return append(out, fmt.Sprintf("%d:%02d", hour, minute))
}
// clockPrepositions — the prepositions that already introduce a time, so the
// rewrite does not put a second one in front of them.
var clockPrepositions = map[string]bool{
"в": true, "во": true, "к": true, "около": true, "на": true, "at": true, "by": true,
}
// cleanWord — one token, lowercased and stripped of the punctuation a spoken
// sentence carries, the same way SpellOutDigits reads its tokens.
func cleanWord(tok string) string {
return strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'"))
}
+75
View File
@@ -0,0 +1,75 @@
package router
import (
"context"
"testing"
"time"
)
// The shape V-538 was filed for. Russian names the hour being entered, so
// "половина восьмого" is 07:30 and never 08:30. Every case here is checked
// against the hour a speaker means, not against the word in the sentence.
func TestRewriteHalfPast(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"напомни в половине восьмого", "напомни в 7:30"},
{"напомни в половине восьмого вечера", "напомни в 7:30 вечера"},
{"половина восьмого", "в 7:30"},
{"к половине девятого", "к 8:30"},
// The hour before one is twelve, not zero.
{"в половине первого", "в 12:30"},
{"в половине двенадцатого", "в 11:30"},
// Contracted, and with no preposition of its own.
{"разбуди полвосьмого", "разбуди в 7:30"},
{"разбуди пол-восьмого", "разбуди в 7:30"},
{"разбуди пол восьмого", "разбуди в 7:30"},
// Quarter-to counts the same way, from a cardinal.
{"без четверти восемь", "в 7:45"},
{"позвони без двадцати восемь", "позвони в 7:40"},
{"без пяти час", "в 12:55"},
// Untouched: not a time.
{"половина яблока", "половина яблока"},
{"без разницы восемь", "без разницы восемь"},
{"отметь последний пункт", "отметь последний пункт"},
{"напомни в 19:30", "напомни в 19:30"},
{"", ""},
} {
if got := rewriteHalfPast(tc.in); got != tc.want {
t.Errorf("rewriteHalfPast(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// A guess in this shape is worse than a refusal, so the minutes a spoken clock
// does not use are left for the parsers to fail on rather than invented.
func TestQuarterToTakesOnlyClockMinutes(t *testing.T) {
for _, in := range []string{"без семи восемь", "без тринадцати восемь"} {
if got := rewriteHalfPast(in); got != in {
t.Errorf("rewriteHalfPast(%q) = %q; those minutes are not a spoken clock", in, got)
}
}
}
// The rewrite is only useful if the parser under it reads the result. The stub
// is the one that always answers, because the host path falls back to it
// whenever python dateparser is missing.
func TestStubParsesAHalfHour(t *testing.T) {
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.Local)
for _, tc := range []struct {
utt string
hour, min int
}{
{"напомни выпить таблетку в половине восьмого вечера", 19, 30},
{"напомни в половине одиннадцатого", 10, 30},
{"разбуди полвосьмого", 7, 30},
{"позвони без четверти восемь", 7, 45},
} {
got, ok, err := (StubDateTimeParser{}).Parse(context.Background(), tc.utt, now)
if err != nil || !ok {
t.Errorf("Parse(%q) = %v, %v, %v; want a time", tc.utt, got, ok, err)
continue
}
if got.Hour() != tc.hour || got.Minute() != tc.min {
t.Errorf("Parse(%q) = %02d:%02d, want %02d:%02d", tc.utt, got.Hour(), got.Minute(), tc.hour, tc.min)
}
}
}
+5
View File
@@ -55,6 +55,11 @@ var numeralContext = map[string]bool{
// stands beside it. "три часа" becomes "3 часа"; "три яблока" stays as it is,
// and a note or a fact carrying a spoken number is untouched.
func SpellOutDigits(text string) string {
// A half hour and a quarter-to hour are phrases rather than numerals, and
// the hour they name is one less than the word in them (V-538). They are
// rewritten whole, before the token pass, and come out as digits it leaves
// alone.
text = rewriteHalfPast(text)
toks := strings.Fields(text)
if len(toks) == 0 {
return text
+1 -1
View File
@@ -10,7 +10,7 @@ func TestSpellOutDigits(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"напомни мне позвонить маме в семь вечера", "напомни мне позвонить маме в 7 вечера"},
{"в три часа дня", "в 3 часа дня"},
{"напомни в половине шестого", "напомни в половине шестого"},
{"напомни в половине шестого", "напомни в 5:30"},
{"через двадцать минут", "через 20 минут"},
// Untouched: no time word stands beside the number.
{"купить три яблока", "купить три яблока"},