diff --git a/internal/memory/behavior.go b/internal/memory/behavior.go index 7356f44..692b2dc 100644 --- a/internal/memory/behavior.go +++ b/internal/memory/behavior.go @@ -45,12 +45,19 @@ type Observation struct { // // TypicalAt is the median time of day it happens at, rounded to the minute — a // median and not a mean, so one 03:00 outlier does not move "он обычно пьёт -// воду утром" into the night. +// воду утром" into the night. It is a CIRCULAR median: the clock wraps, and a +// plain median of minutes-since-midnight reports 12:00 for a man who goes to +// bed at 23:50. +// +// HasTypical is false when the times are spread too widely for any of them to +// be typical (see maxTypicalSpread). She then names the habit without a time +// instead of naming a time she cannot support. type Activity struct { - Key string - Days int - Count int - TypicalAt time.Duration + Key string + Days int + Count int + TypicalAt time.Duration + HasTypical bool } // Profile — the counted behaviour model. @@ -82,13 +89,20 @@ const MinHabitDays = 2 // nonBehaviouralKeyPrefixes — keys that are machinery or one-shot records, not // behaviour. Calendar events carry the day in the key so they can never repeat; // cooldown and quiet rows are maven's own tuning state, not his habits. +// quiet is listed with its separators rather than bare: as a five-letter +// prefix it would also swallow any future self-fact key that merely starts +// with those letters. var nonBehaviouralKeyPrefixes = []string{ "calendar_event_", "cooldown:", - "quiet", + "quiet_", + "quiet:", "behavior_profile", } +// nonBehaviouralKeys — exact keys, for the ones with no separator to anchor on. +var nonBehaviouralKeys = map[string]bool{"quiet": true} + // BuildProfile counts habits out of observations. now bounds the window's upper // end and supplies the location every day boundary is taken in — a habit is // "on Tuesdays" in the owner's timezone or it is nothing. @@ -113,7 +127,7 @@ func BuildProfile(obs []Observation, now time.Time) Profile { if o.Kind != "self" { continue } - key := strings.TrimSpace(o.Key) + key := canonicalize(o.Key) if key == "" || nonBehavioural(key) { continue } @@ -151,11 +165,13 @@ func BuildProfile(obs []Observation, now time.Time) Profile { if len(b.days) < MinHabitDays { continue } + mid, known := circularMedianMinutes(b.mins) out = append(out, Activity{ - Key: key, - Days: len(b.days), - Count: b.count, - TypicalAt: time.Duration(medianInt(b.mins)) * time.Minute, + Key: key, + Days: len(b.days), + Count: b.count, + TypicalAt: time.Duration(mid) * time.Minute, + HasTypical: known, }) } // Most-established first, then earliest in the day, then by key so the @@ -208,7 +224,24 @@ func BuildProfile(obs []Observation, now time.Time) Profile { return p } +// canonicalize maps a fact key onto the key the profile counts it under. +// Lowercased, trimmed, and separators folded to "_" before the alias lookup, +// so "Выпил воды", "выпил-воды" and "выпил_воды" are one habit and not three. +// An unlisted key counts as itself. +func canonicalize(key string) string { + k := strings.ToLower(strings.TrimSpace(key)) + k = strings.NewReplacer(" ", "_", "-", "_").Replace(k) + k = strings.Trim(k, "_") + if c, ok := canonicalKey[k]; ok { + return c + } + return k +} + func nonBehavioural(key string) bool { + if nonBehaviouralKeys[key] { + return true + } for _, p := range nonBehaviouralKeyPrefixes { if strings.HasPrefix(key, p) { return true @@ -217,6 +250,63 @@ func nonBehavioural(key string) bool { return false } +// minutesPerDay — the modulus every clock time is taken in. +const minutesPerDay = 24 * 60 + +// maxTypicalSpread — how far apart the observations of one activity may sit, +// once rotated onto the shortest arc, before "usually at X" stops being a +// claim about anything. Half a day: wider than that and the values cover the +// clock, so no point on it is typical. +const maxTypicalSpread = minutesPerDay / 2 + +// circularMedianMinutes — the median time of day, on a clock rather than on a +// number line. Reports (0, false) when the values are too spread out to have a +// middle. +// +// A plain median of minutes-since-midnight is wrong for anything that straddles +// midnight, which is exactly the activity most likely to: bedtimes of 23:40, +// 23:50, 00:10 and 00:20 average out to 720 minutes, and she says "обычно ты +// спишь около 12:00". The fix is to find the rotation of the sorted values with +// the shortest span — the arc the observations actually occupy — take the +// ordinary median inside it, and wrap the answer back into the day. +func circularMedianMinutes(xs []int) (int, bool) { + if len(xs) == 0 { + return 0, false + } + s := make([]int, len(xs)) + for i, x := range xs { + s[i] = ((x % minutesPerDay) + minutesPerDay) % minutesPerDay + } + sort.Ints(s) + + // Each rotation cuts the day at one observation and unwraps the values + // before the cut onto the following day. The cut with the smallest span is + // the one where no observation is on the far side of midnight from the rest. + best, bestSpan := 0, minutesPerDay+1 + for i := range s { + span := s[(i+len(s)-1)%len(s)] - s[i] + if i > 0 { + span += minutesPerDay + } + if span < bestSpan { + best, bestSpan = i, span + } + } + if bestSpan > maxTypicalSpread { + return 0, false + } + rot := make([]int, 0, len(s)) + for i := 0; i < len(s); i++ { + v := s[(best+i)%len(s)] + if best+i >= len(s) { + v += minutesPerDay + } + rot = append(rot, v) + } + m := medianInt(rot) % minutesPerDay + return m, true +} + // medianInt — the middle value, averaging the two middles on an even count. func medianInt(xs []int) int { if len(xs) == 0 { @@ -253,15 +343,44 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string { return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.", day, joinActivities(p.Everyday)) } - return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day) + return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day) } -// FormatOverallRU reads back the habits that hold across the whole week. +// FormatOverallRU reads back the habits that hold across the whole week, and +// says over what stretch of records it is claiming them. +// +// The period is spoken because "обычно" without one is an unfalsifiable claim +// about his life: the same sentence comes out of three days of taps and out of +// a year of them, and only one of those is worth believing. func (p Profile) FormatOverallRU() string { if len(p.All) == 0 { return "я ещё не набрала достаточно записей, чтобы говорить о привычках." } - return fmt.Sprintf("обычно ты %s.", joinActivities(p.All)) + return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU()) +} + +// spanRU — "по записям за последние N дней", or a vaguer phrase when the window +// is too short to name in days. +func (p Profile) spanRU() string { + if p.Since.IsZero() || !p.Until.After(p.Since) { + return "по записям за сегодня" + } + days := int(p.Until.Sub(p.Since).Hours()/24) + 1 + return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days)) +} + +// pluralDaysRU — the Russian count form of "день" for n. +func pluralDaysRU(n int) string { + switch { + case n%100 >= 11 && n%100 <= 14: + return "дней" + case n%10 == 1: + return "день" + case n%10 >= 2 && n%10 <= 4: + return "дня" + default: + return "дней" + } } // maxRecited bounds a spoken profile. A list of fifteen habits read aloud is @@ -276,7 +395,15 @@ func joinActivities(acts []Activity) string { for i, a := range acts { gloss, ok := activityRU[a.Key] if !ok { - gloss = a.Key + // No gloss: quote the key instead of reading it as a verb. The keys + // come from the model, so an unglossed one is as likely to be + // "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is + // not a sentence. + gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " ")) + } + if !a.HasTypical { + parts[i] = gloss + continue } parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss, int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60) diff --git a/internal/memory/behavior_ru.go b/internal/memory/behavior_ru.go index 6354e93..49fbe65 100644 --- a/internal/memory/behavior_ru.go +++ b/internal/memory/behavior_ru.go @@ -22,15 +22,18 @@ import ( var behaviorRUJSON []byte type behaviorRU struct { - Weekdays []string `json:"weekdays"` - Activities map[string]string `json:"activities"` + Weekdays []string `json:"weekdays"` + Activities map[string]string `json:"activities"` + KeyAliases map[string][]string `json:"key_aliases"` } var ( // weekdayRU — dative plural, indexed by time.Weekday. weekdayRU []string - // activityRU — fact key to second-person-singular verb phrase. + // activityRU — canonical fact key to second-person-singular verb phrase. activityRU map[string]string + // canonicalKey — observed fact key to the canonical key it counts as. + canonicalKey map[string]string ) func init() { @@ -42,4 +45,11 @@ func init() { panic(fmt.Sprintf("memory: behavior_ru.json: want 7 weekdays, got %d", len(v.Weekdays))) } weekdayRU, activityRU = v.Weekdays, v.Activities + canonicalKey = make(map[string]string) + for canonical, variants := range v.KeyAliases { + canonicalKey[canonical] = canonical + for _, variant := range variants { + canonicalKey[variant] = canonical + } + } } diff --git a/internal/memory/behavior_ru.json b/internal/memory/behavior_ru.json index 8bdfcdd..a53147c 100644 --- a/internal/memory/behavior_ru.json +++ b/internal/memory/behavior_ru.json @@ -9,8 +9,15 @@ "plural, because both 'по вторникам' and 'по средам' need that form.", "", "activities glosses a fact key as a second-person-singular verb phrase. An", - "unknown key is read back verbatim rather than guessed at: inventing a", - "Russian phrase for a key maven does not recognise puts words in his mouth." + "unknown key is not glossed and is quoted rather than guessed at: inventing", + "a Russian phrase for a key maven does not recognise puts words in his", + "mouth, and reading the raw key inline produces 'обычно ты выпил_воды'.", + "", + "key_aliases maps the fact keys the router actually emits onto the canonical", + "key the profile counts. The key of a self fact comes straight out of the", + "LLM with no allowlist behind it, so 'я выпил воду' and 'попил воды' arrive", + "as different keys, split one habit into two, and drop both below the", + "day threshold. Additive and lowercase; an unlisted key counts as itself." ], "weekdays": [ "воскресеньям", @@ -30,5 +37,15 @@ "walk": "гуляешь", "pills": "пьёшь витамины", "workout": "тренируешься" + }, + "key_aliases": { + "water": ["вода", "воду", "воды", "водичка", "попил", "попил_воды", "выпил_воды", "выпил_воду", "drink_water", "drank_water"], + "meal": ["еда", "еду", "поел", "поесть", "завтрак", "обед", "ужин", "food", "ate", "breakfast", "lunch", "dinner"], + "sleep": ["сон", "спать", "лег", "лёг", "уснул", "заснул", "bedtime", "slept"], + "break": ["перерыв", "отдых", "пауза", "rest", "pause"], + "shower": ["душ", "принял_душ", "помылся", "мытьё", "мылся"], + "walk": ["прогулка", "гулял", "погулял", "выгулял", "walked", "walking"], + "pills": ["витамины", "таблетки", "таблетка", "лекарство", "vitamins", "meds", "medicine"], + "workout": ["тренировка", "тренировался", "потренировался", "зал", "спорт", "exercise", "gym", "training"] } } diff --git a/internal/memory/behavior_test.go b/internal/memory/behavior_test.go index d257667..e70fe0d 100644 --- a/internal/memory/behavior_test.go +++ b/internal/memory/behavior_test.go @@ -207,3 +207,146 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) { t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed) } } + +// TestTypicalTimeIsCircular — the median is over a clock, not a number line. +// Bedtimes either side of midnight used to average to midday, which is the +// exact error the median was chosen to avoid, on the one activity most likely +// to cross the boundary. +func TestTypicalTimeIsCircular(t *testing.T) { + now := behaviorNow() + var obs []Observation + for i, mins := range []int{23*60 + 40, 23*60 + 50, 10, 20} { + day := now.AddDate(0, 0, -(i + 1)) + obs = append(obs, Observation{ + At: time.Date(day.Year(), day.Month(), day.Day(), 0, mins, 0, 0, now.Location()), + Key: "sleep", + Kind: "self", + }) + } + p := BuildProfile(obs, now) + if len(p.All) != 1 { + t.Fatalf("got %+v, want one activity", p.All) + } + got := p.All[0].TypicalAt + if !p.All[0].HasTypical { + t.Fatal("a four-observation cluster has a typical time") + } + if got < 23*time.Hour+55*time.Minute && got > 5*time.Minute { + t.Errorf("typical bedtime = %v, want just either side of midnight", got) + } + if s := p.FormatOverallRU(); strings.Contains(s, "около 12:00") { + t.Errorf("read back as %q", s) + } +} + +// Times spread across the whole clock have no typical value, and she must not +// name one. +func TestNoTypicalTimeWhenSpreadWide(t *testing.T) { + now := behaviorNow() + var obs []Observation + for i, hh := range []int{2, 9, 16, 21} { + day := now.AddDate(0, 0, -(i + 1)) + obs = append(obs, Observation{ + At: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()), + Key: "water", + Kind: "self", + }) + } + p := BuildProfile(obs, now) + if len(p.All) != 1 { + t.Fatalf("got %+v, want one activity", p.All) + } + if p.All[0].HasTypical { + t.Errorf("times spanning %v were given a typical value", p.All[0].TypicalAt) + } + if s := p.FormatOverallRU(); strings.Contains(s, "около") { + t.Errorf("read back with a time she cannot support: %q", s) + } +} + +// TestKeysAreCanonicalisedBeforeCounting — the fact key comes out of the LLM +// with no allowlist behind it, so the same habit arrives spelled several ways. +// Counted separately, each spelling sits below MinHabitDays and the habit +// vanishes. +func TestKeysAreCanonicalisedBeforeCounting(t *testing.T) { + now := behaviorNow() + var obs []Observation + for i, key := range []string{"water", "Воду", "выпил воды", "попил_воды"} { + day := now.AddDate(0, 0, -(i + 1)) + obs = append(obs, Observation{ + At: time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, now.Location()), + Key: key, + Kind: "self", + }) + } + p := BuildProfile(obs, now) + if len(p.All) != 1 { + t.Fatalf("got %+v, want one activity — the spellings are one habit", p.All) + } + if p.All[0].Key != "water" || p.All[0].Days != 4 { + t.Errorf("got %+v, want water on 4 days", p.All[0]) + } + if s := p.FormatOverallRU(); !strings.Contains(s, "пьёшь воду") { + t.Errorf("read back as %q, want the glossed canonical key", s) + } +} + +// An unglossed key is quoted, not read as a verb. "обычно ты выпил_воды около +// 09:00" is what reciting the raw key produced. +func TestUnglossedKeyIsQuoted(t *testing.T) { + now := behaviorNow() + p := BuildProfile(habitHistory("починил_кран", time.Tuesday, 12, 0, 2, now), now) + got := p.FormatOverallRU() + if !strings.Contains(got, "отмечаешь «починил кран»") { + t.Errorf("got %q", got) + } +} + +// She says over what stretch of records "обычно" is claimed. Without it the +// same sentence comes out of three days and out of a year. +func TestOverallNamesThePeriod(t *testing.T) { + now := behaviorNow() + p := BuildProfile(habitHistory("water", time.Tuesday, 9, 0, 3, now), now) + got := p.FormatOverallRU() + if !strings.Contains(got, "по записям за последние 21 день") { + t.Errorf("got %q, want the period spoken", got) + } +} + +// The no-data weekday answer is about him, not about her. "у меня пока нет +// ничего постоянного" answers a question nobody asked. +func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) { + p := BuildProfile(nil, behaviorNow()) + got := p.FormatWeekdayRU(time.Wednesday) + if strings.Contains(got, "у меня") { + t.Errorf("got %q", got) + } + if !strings.Contains(got, "у тебя") { + t.Errorf("got %q, want an answer about him", got) + } +} + +// "quiet" is machinery, but only as a whole key. As a bare five-letter prefix +// it silently swallowed any future self-fact key starting with those letters. +func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) { + now := behaviorNow() + p := BuildProfile(habitHistory("quietude", time.Tuesday, 8, 0, 3, now), now) + if len(p.All) != 1 { + t.Fatalf("got %+v, want the key counted", p.All) + } + p = BuildProfile(habitHistory("quiet_hours", time.Tuesday, 8, 0, 3, now), now) + if len(p.All) != 0 { + t.Fatalf("got %+v, want maven's own tuning state dropped", p.All) + } +} + +func TestPluralDaysRU(t *testing.T) { + for _, c := range []struct { + n int + want string + }{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} { + if got := pluralDaysRU(c.n); got != c.want { + t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want) + } + } +}