Compare commits

...

3 Commits

Author SHA1 Message Date
claude 0b994ff1c3 media store: a failed write gives its budget reservation back (V-584)
Put and PutFile added the blob size to s.total before writing, and only the
writeFile and os.Rename failure paths released it. A writeMeta failure in
either, and a chmod failure on the spool in PutFile, kept the size, so a store
that hit a full disk over-counted itself and could answer ErrStoreFull while
the disk had room until the next Open re-measured.

One defer per function now owns the release, disarmed on the success return,
so a future early return cannot reintroduce the leak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:38:12 +04:00
claude 0b1efe4911 Merge the hour and minute units, and the preposition that was the real cause (#252)
The measured symptom was that напомни к двум часам позвонить маме answered
Когда? while к трём read the hour. The filed cause was that часам is missing
from four hour-unit sets in the router while the lexicon already lists it. That
was true and it was not the cause.

NamesAnHour already returned true for the failing sentence. The gap was HasTime,
and the parser never read it, because hourPrepositions in slots.go knew в, во
and на and not к. The dateparser rewrite carried the same three prepositions and
the same short hour forms. Both take к and ко now, and the oblique hour with
them. The sentence parses to two o'clock and the turn asks утра или вечера?,
which is the answer к трём already gave.

The filed defect is fixed too, since it is a fifth copy of a closed class either
way. hour_units and minute_units are lexicon sets now, validated at load, and
the four router sites read them. минутам had the same gap in all four sets.
SlotValueFrame appends both sets, so the old copy at line 220 is gone rather
than left to drift.

Three new tests, all of which fail on master.

The fixture did not move. The classifier and hash arm scores 27/91 before and
after, and reach is 18/30 before and after. The ONNX and LLM arms were not
measured, since neither MAVEN_ONNX_LIB nor MAVEN_LLM_URL is set in a worktree,
so judge the cascade number again on the box.

(V-609)
2026-08-06 03:51:38 +04:00
claude 580959f856 The hour unit has one home and it carries the dative plural (V-609)
"напомни к двум часам позвонить маме" now reads two o'clock. It read no
time at all, so the reminder reached the daemon with an empty slot and she
asked the open "Когда?" about an hour he had just said.

The word that lost it was "часам", the dative plural of "час". Four sets in
internal/router listed the hour noun and every one of them stopped at
"часу". They are now one lexicon key, hour_units, read by all four through
lexicon.HourUnits and lexicon.IsHourUnit. The minute noun had the same gap
one word over and gets the same treatment in minute_units: "минутам" was
missing everywhere "минут" and "минуты" were present. The slot_value_frame
set no longer lists either noun and appends both, so there is one copy of
each closed class rather than a copy per caller.

Two more sites had to move for the sentence to parse. hourPrepositions knew
"в", "во" and "на" and not "к", and the python dateparser rewrite knew the
same three. Both now read the fifth preposition and the oblique forms of the
hour that follow it.

Fixture unchanged: classifier+hash 27/91 before and after, reach 18/30
before and after, no case moved in either direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:51:06 +04:00
9 changed files with 265 additions and 32 deletions
+35 -2
View File
@@ -65,7 +65,7 @@ func mustLoad() lexiconFile {
"day_offsets", "weekdays", "weekdays_english", "months_genitive", "hours_spoken",
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
"filler_particles", "task_done_words", "task_drop_words",
"confirm_yes", "confirm_no",
"confirm_yes", "confirm_no", "hour_units", "minute_units",
} {
s, ok := f.Sets[name]
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
@@ -139,7 +139,40 @@ func TaskDropWords() []string { return words("task_drop_words") }
// making the utterance a request of its own. A caller strips these (along with
// the numbers and the other closed time sets) to see whether an utterance
// carries any content beside the value it was asked for. See the set's note.
func SlotValueFrame() []string { return words("slot_value_frame") }
// The hour and the minute nouns are part of the frame and are kept in their own
// sets, so there is one copy of each closed class rather than a copy per caller.
func SlotValueFrame() []string {
out := words("slot_value_frame")
out = append(out, HourUnits()...)
out = append(out, MinuteUnits()...)
return out
}
// HourUnits returns every form of the hour noun, and MinuteUnits every form of
// the minute noun. One home for each, because four router sets used to list the
// hour and all four stopped at "часу" (V-609). A caller folding time words into
// one set reads these; a caller asking about a single word reads IsHourUnit or
// IsMinuteUnit.
func HourUnits() []string { return words("hour_units") }
// MinuteUnits — see HourUnits.
func MinuteUnits() []string { return words("minute_units") }
// IsHourUnit reports whether a word is the hour noun in any form.
func IsHourUnit(word string) bool { return inSet("hour_units", word) }
// IsMinuteUnit reports whether a word is the minute noun in any form.
func IsMinuteUnit(word string) bool { return inSet("minute_units", word) }
func inSet(set, word string) bool {
w := norm(word)
for _, s := range ru.Sets[set].Words {
if w == s {
return true
}
}
return false
}
// DialogueCancel returns the ways he calls off the request Maven is assembling.
// Distinct from TaskDropWords, which abandons an item that already exists.
+16 -2
View File
@@ -210,6 +210,20 @@
"передумал", "передумала", "неактуально"
]
},
"hour_units": {
"note": "Every form of the hour noun, Russian and English (V-609). One home for a closed class that four router sets used to list separately, and all four stopped at \"часу\": \"напомни к двум часам\" lost its hour and the reminder was left asking \"Когда?\". Russian declines, so the dative plural is as ordinary a way to say an hour as the accusative singular. A caller that folds time words into one set reads HourUnits; a caller asking about one word reads IsHourUnit.",
"words": [
"час", "часа", "часов", "часу", "часам", "часами", "часах",
"hour", "hours"
]
},
"minute_units": {
"note": "Every form of the minute noun, Russian and English (V-609). Same class as hour_units one noun over, and it had the same gap: the dative plural \"минутам\" was missing everywhere \"минут\" and \"минуты\" were present.",
"words": [
"минута", "минуты", "минуту", "минут", "минуте", "минутам", "минутами", "минутах",
"minute", "minutes"
]
},
"slot_value_frame": {
"note": "The words that can stand around a bare slot value without making the utterance a request of its own (Vikunja #560). Prepositions, hedges and the nouns a spoken time is built from: strip these, the numbers, the interrogatives, the filler particles and the other time sets, and whatever is left is the utterance's OWN content. \"а что если в 11:00\" leaves nothing and is an answer; \"какая сейчас погода в Риме\" leaves \"погода\" and \"Риме\" and is not. Closed because each part of it is closed — Russian has a fixed list of prepositions, and a clock is built from a fixed list of nouns. It is not a stopword list: a word goes in only if it can never be the thing he is asking about.",
"words": [
@@ -217,10 +231,10 @@
"at", "on", "in", "by", "to", "till", "until", "after", "before", "about", "for",
"нет", "не", "да", "ага", "угу", "ой", "ох", "тогда", "лучше", "может", "можно", "наверное", "наверно", "пожалуй", "точнее", "скорее", "если", "пусть", "прости", "извини", "слушай", "значит", "как-то", "типа", "вообще-то",
"no", "yes", "yeah", "ok", "okay", "sorry", "maybe", "actually", "rather", "then", "well",
"час", "часа", "часов", "часу", "часам", "минут", "минута", "минуты", "минуту", "минутах", "полдень", "полночь", "полдня",
"полдень", "полночь", "полдня",
"утра", "утро", "утру", "дня", "день", "днями", "вечера", "вечер", "вечеру", "ночи", "ночь", "ночью",
"сейчас", "теперь", "сегодняшний", "ближайший", "ближайшее",
"hour", "hours", "minute", "minutes", "noon", "midnight", "am", "pm", "oclock", "now"
"noon", "midnight", "am", "pm", "oclock", "now"
]
},
"dialogue_cancel": {
+22 -4
View File
@@ -265,6 +265,16 @@ func (s *Store) Put(kind Kind, mime, source string, data []byte) (Blob, error) {
}
}
// Every return past the reservation has to give it back, so the defer owns
// that rather than each error path: a path that forgot over-counted the
// store until the next Open re-walked the directory.
stored := false
defer func() {
if fresh && !stored {
s.release(b.Size)
}
}()
// The sidecar goes first. Written second, a full disk or a crash between
// the two left the bytes on disk with no sidecar, and List only sees
// sidecars, so Prune could never collect them: Put returned an error and an
@@ -274,11 +284,9 @@ func (s *Store) Put(kind Kind, mime, source string, data []byte) (Blob, error) {
}
if err := writeFile(blobPath, data); err != nil {
_ = os.Remove(metaPath)
if fresh {
s.release(b.Size)
}
return Blob{}, err
}
stored = true
return b, nil
}
@@ -331,12 +339,22 @@ func (s *Store) PutFile(kind Kind, mime, source, src string) (Blob, error) {
return Blob{}, err
}
}
// Same reasoning as Put: the reservation is released by one defer, not by
// whichever error path remembered to.
stored := false
defer func() {
if fresh && !stored {
s.release(b.Size)
}
}()
if err := writeMeta(metaPath, b); err != nil {
return Blob{}, err
}
if !fresh {
// Same bytes already here. Drop the spool copy.
_ = os.Remove(src)
stored = true
return b, nil
}
if err := os.Chmod(src, filePerm); err != nil {
@@ -344,9 +362,9 @@ func (s *Store) PutFile(kind Kind, mime, source, src string) (Blob, error) {
}
if err := os.Rename(src, blobPath); err != nil {
_ = os.Remove(metaPath)
s.release(b.Size)
return Blob{}, fmt.Errorf("media: move spool: %w", err)
}
stored = true
return b, nil
}
+67
View File
@@ -287,6 +287,73 @@ func TestPutLeavesNothingWhenTheBytesCannotBeWritten(t *testing.T) {
}
}
// An over-counted store answers ErrStoreFull while the disk has room, and only
// the next Open corrects it. So every failed write has to give its reservation
// back, not just the one that remembered to.
func TestPutReleasesTheBudgetWhenTheSidecarCannotBeWritten(t *testing.T) {
s := testStore(t)
data := []byte("no sidecar for this")
blockSidecar(t, s, KindImage, data)
if _, err := s.Put(KindImage, "image/png", "web:upload", data); err == nil {
t.Fatal("put must fail")
}
if s.Total() != 0 {
t.Errorf("total = %d, want the failed put not counted", s.Total())
}
}
func TestPutFileReleasesTheBudgetWhenTheSidecarCannotBeWritten(t *testing.T) {
s := testStore(t)
data := []byte("no sidecar for this either")
blockSidecar(t, s, KindAudio, data)
src := filepath.Join(t.TempDir(), "capture.wav")
if err := os.WriteFile(src, data, 0o600); err != nil {
t.Fatal(err)
}
if _, err := s.PutFile(KindAudio, "audio/wav", "meeting", src); err == nil {
t.Fatal("put file must fail")
}
if s.Total() != 0 {
t.Errorf("total = %d, want the failed put not counted", s.Total())
}
}
// The chmod arm is PutFile's alone: Put never touches a spool file.
func TestPutFileReleasesTheBudgetWhenTheSpoolCannotBeChmodded(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root can chmod a file it does not own")
}
// A symlink to a file owned by somebody else. Stat and the hash follow it
// and succeed; chmod follows it too and is refused.
src := filepath.Join(t.TempDir(), "capture.wav")
if err := os.Symlink("/etc/hosts", src); err != nil {
t.Fatal(err)
}
info, err := os.Stat(src)
if err != nil || info.Size() == 0 {
t.Skip("no readable /etc/hosts to point at")
}
s := testStore(t)
if _, err := s.PutFile(KindAudio, "audio/wav", "meeting", src); err == nil {
t.Fatal("put file must fail")
}
if s.Total() != 0 {
t.Errorf("total = %d, want the failed put not counted", s.Total())
}
}
// blockSidecar puts a directory where the sidecar for data has to go, so
// writeMeta fails while the blob path is still free.
func blockSidecar(t *testing.T, s *Store, kind Kind, data []byte) {
t.Helper()
sum := sha256.Sum256(data)
id := hex.EncodeToString(sum[:])
bucket := filepath.Join(s.dir, string(kind), id[:2])
if err := os.MkdirAll(filepath.Join(bucket, id+".json"), 0o700); err != nil {
t.Fatal(err)
}
}
// The per-blob cap bounds one call and nothing bounded their sum. 64 MiB per
// call times unlimited calls inside a seven-day window fills the disk mavend's
// database lives on.
+7 -5
View File
@@ -45,10 +45,10 @@ try:
now = datetime.fromisoformat(sys.argv[2])
# Pre-process: replace Russian time qualifiers with AM/PM.
# Handles "9 утра", "10 часов утра", "3 часа дня" etc.
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?утра\b', r'\1 am', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?утра\b', r'\1 am', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE)
# A bare hour after a preposition is dropped on the floor by dateparser:
# "завтра в 7" resolves to tomorrow at the CURRENT clock, and "завтра в 7
# часов" is read as seven hours from now. Only a qualifier (already an
@@ -56,7 +56,9 @@ try:
# English "at 7" fails identically, so both prepositions are rewritten.
# "на 9" is the same hour said with the other preposition, and it was not
# read at all until V-579: "в 9" set the reminder and "на 9" did not.
text = re.sub(r'(?<![\w:])(в|во|на|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
# "к двум часам" is a third preposition and the dative that goes with it,
# and it was read as no time at all until V-609.
text = re.sub(r'(?<![\w:])(в|во|на|к|ко|at|by)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов|у|ам)?)?(?![\d:.\w])',
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
# Two-step: search_dates finds the date substring in text,
+74
View File
@@ -0,0 +1,74 @@
package router
import (
"context"
"testing"
"time"
)
// TestDativePluralHourIsAnHour — "напомни к двум часам позвонить маме" reached
// the daemon with no time at all and she asked the open "Когда?", while "к трём"
// one word over read fine (V-609). The word that lost it was "часам", the dative
// plural of "час", which four separate hour sets in this package left out.
func TestDativePluralHourIsAnHour(t *testing.T) {
const s = "напомни к двум часам позвонить маме"
if !MentionsTime(s) {
t.Errorf("MentionsTime(%q) = false; the sentence names two o'clock", s)
}
if !NamesAnHour(s) {
t.Errorf("NamesAnHour(%q) = false; the sentence names two o'clock", s)
}
if got, want := SpellOutDigits(s), "напомни к 2 часам позвонить маме"; got != want {
t.Errorf("SpellOutDigits(%q) = %q, want %q", s, got, want)
}
// The slot itself, which is what the daemon reads. It was empty, so
// whenGapOf named the hour missing and she asked "Когда?".
now := time.Date(2026, 8, 6, 3, 39, 0, 0, time.UTC)
ex := Extractor{Time: StubDateTimeParser{}}
got := ex.Extract(context.Background(), IntentReminder, s, now)
if !got.HasTime {
t.Fatalf("the hour was spoken, so the slot must be filled: %+v", got)
}
if h := got.Time.Hour(); h != 2 && h != 14 {
t.Errorf("fire time = %s, want two o'clock in one half of the day or the other", got.Time.Format("15:04"))
}
}
// TestHourUnitReachesEverySite — the four sets that read the hour noun now read
// one lexicon key, so a form added there is a form all four know. "часам" is the
// form that was missing from every one of them.
func TestHourUnitReachesEverySite(t *testing.T) {
for _, w := range []string{"час", "часа", "часов", "часу", "часам"} {
if !numeralContext[w] {
t.Errorf("numeralContext is missing %q", w)
}
if !hourMarkers[w] {
t.Errorf("hourMarkers is missing %q", w)
}
if !timeMarkers[w] {
t.Errorf("timeMarkers is missing %q", w)
}
if _, ok := unitToDuration(2, w); !ok {
t.Errorf("unitToDuration does not know %q", w)
}
}
}
// TestMinuteUnitHasTheSameForms — the same defect one noun over: "минутам" was
// missing everywhere "минут" and "минуты" were present.
func TestMinuteUnitHasTheSameForms(t *testing.T) {
for _, w := range []string{"минут", "минуты", "минуту", "минутам"} {
if !numeralContext[w] {
t.Errorf("numeralContext is missing %q", w)
}
if !hourMarkers[w] {
t.Errorf("hourMarkers is missing %q", w)
}
if !timeMarkers[w] {
t.Errorf("timeMarkers is missing %q", w)
}
if _, ok := unitToDuration(20, w); !ok {
t.Errorf("unitToDuration does not know %q", w)
}
}
}
+15 -6
View File
@@ -34,12 +34,21 @@ func numeralDigit(word string) (string, bool) {
// numeralContext — the words that make a numeral a time. A numeral is only
// rewritten when one of these sits next to it, so "три яблока" in a note is
// left alone and "в три часа" is not.
var numeralContext = map[string]bool{
"в": true, "во": true, "к": true, "около": true, "на": true,
"часа": true, "часов": true, "час": true, "часу": true,
"утра": true, "вечера": true, "дня": true, "ночи": true,
"минут": true, "минуты": true, "минуту": true,
"at": true, "by": true,
var numeralContext = buildNumeralContext()
func buildNumeralContext() map[string]bool {
m := map[string]bool{
"в": true, "во": true, ": true, "около": true, "на": true,
"утра": true, "вечера": true, "дня": true, "ночи": true,
"at": true, "by": true,
}
for _, w := range lexicon.HourUnits() {
m[w] = true
}
for _, w := range lexicon.MinuteUnits() {
m[w] = true
}
return m
}
// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the
+17 -9
View File
@@ -175,10 +175,14 @@ func afterWord(s, w string) string {
return ""
}
// hourPrepositions — the words a spoken hour sits behind. Three, and no more:
// hourPrepositions — the words a spoken hour sits behind. Five, and no more:
// the lexicon's frame set is much wider, and a word goes in here only when the
// number after it is an hour of the day rather than a count of anything.
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": true}
//
// "к" and "ко" joined the three on V-609. "напомни к двум часам" named an hour
// and parsed to nothing, so the reminder reached the daemon with no time and she
// asked the open question about an hour he had just said.
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": true, "к": true, "ко": true}
// StubDateTimeParser — a tiny relative/absolute parser standing in for
// `dateparser` until the i18n module lands. Handles "in Nh"/"in Nm"/"in Ns" and
@@ -398,10 +402,18 @@ func leadingWordNumber(s string) (int, string, bool) {
}
func unitToDuration(n int, unit string) (time.Duration, bool) {
switch unit {
case "h", "hour", "hours", "hr", "hrs":
// The hour and the minute nouns are closed classes with one home in the
// lexicon, and the list here used to be short of the oblique forms (V-609).
if lexicon.IsHourUnit(unit) {
return time.Duration(n) * time.Hour, true
case "m", "min", "mins", "minute", "minutes":
}
if lexicon.IsMinuteUnit(unit) {
return time.Duration(n) * time.Minute, true
}
switch unit {
case "h", "hr", "hrs":
return time.Duration(n) * time.Hour, true
case "m", "min", "mins":
return time.Duration(n) * time.Minute, true
case "s", "sec", "secs", "second", "seconds":
return time.Duration(n) * time.Second, true
@@ -409,10 +421,6 @@ func unitToDuration(n int, unit string) (time.Duration, bool) {
case "day", "days":
return time.Duration(n) * 24 * time.Hour, true
// Russian units (inflected forms)
case "час", "часа", "часов":
return time.Duration(n) * time.Hour, true
case "минута", "минуты", "минут":
return time.Duration(n) * time.Minute, true
case "день", "дня", "дней":
return time.Duration(n) * 24 * time.Hour, true
case "неделя", "недели", "недель":
+12 -4
View File
@@ -206,12 +206,16 @@ var hourMarkers = buildHourMarkers()
func buildHourMarkers() map[string]bool {
m := map[string]bool{
"утра": true, "вечера": true, "дня": true, "ночи": true,
"часа": true, "часов": true, "час": true, "часу": true,
"минут": true, "минуты": true, "минуту": true,
"через": true, "спустя": true, "полчаса": true,
"полдень": true, "полночь": true,
"am": true, "pm": true, "noon": true, "midnight": true, "in": true,
}
for _, w := range lexicon.HourUnits() {
m[w] = true
}
for _, w := range lexicon.MinuteUnits() {
m[w] = true
}
for _, w := range lexicon.PartsOfDay() {
m[w] = true
}
@@ -274,11 +278,15 @@ var timeMarkers = buildTimeMarkers()
func buildTimeMarkers() map[string]bool {
m := map[string]bool{
"утра": true, "вечера": true, "дня": true, "ночи": true,
"часа": true, "часов": true, "час": true, "часу": true,
"минут": true, "минуты": true, "минуту": true,
"через": true, "полчаса": true, "сейчас": true,
"am": true, "pm": true, "noon": true, "midnight": true,
}
for _, w := range lexicon.HourUnits() {
m[w] = true
}
for _, w := range lexicon.MinuteUnits() {
m[w] = true
}
for _, w := range lexicon.PartsOfDay() {
m[w] = true
}