Files
Maven/internal/memory/behavior_test.go
T
kami ba33a677f8 memory: canonicalise habit keys, take the median on a clock, name the period
Three defects in how the counted profile is read back.

The counting unit was the key the LLM invented. There is no allowlist and no
normalization behind it, so "я выпил воду" and "попил воды" landed as different
keys, split one habit into two, and dropped both below the two-day threshold.
Keys now go through an alias table in behavior_ru.json before they are counted.
An unglossed key is quoted rather than recited as a verb, because "обычно ты
выпил_воды около 09:00" is not a sentence.

The typical time was a median of minutes since midnight, which is wrong for
anything that straddles midnight. Bedtimes of 23:40, 23:50, 00:10 and 00:20
gave 12:00, on the one activity most likely to cross the boundary. It is now a
circular median, and when the observations span more than half the clock she
names the habit without a time instead of inventing one.

The rest is wording. Profile.Since was computed and never spoken, so "обычно"
was an unfalsifiable claim; the overall read-back now says over how many days
of records it holds. The no-data weekday answer said "у меня пока нет ничего
постоянного" about a question concerning him. And "quiet" was a bare prefix in
the non-behavioural list, so any future self-fact key starting with those five
letters would have been dropped.

Found in review of #59.
2026-08-01 14:04:24 +04:00

353 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"strings"
"testing"
"time"
"unicode"
)
// habitHistory — n weeks of the same weekday, at the given local time.
func habitHistory(key string, wd time.Weekday, hh, mm, weeks int, from time.Time) []Observation {
var out []Observation
d := from
for d.Weekday() != wd {
d = d.AddDate(0, 0, -1)
}
for i := 0; i < weeks; i++ {
day := d.AddDate(0, 0, -7*i)
out = append(out, Observation{
At: time.Date(day.Year(), day.Month(), day.Day(), hh, mm, 0, 0, from.Location()),
Key: key,
Kind: "self",
})
}
return out
}
func behaviorNow() time.Time {
// A Monday, so "по вторникам" is a past weekday and not today.
return time.Date(2026, 8, 3, 20, 0, 0, 0, time.UTC)
}
func TestBuildProfileCountsWeekdayHabits(t *testing.T) {
now := behaviorNow()
obs := append(
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
habitHistory("water", time.Tuesday, 9, 0, 3, now)...,
)
p := BuildProfile(obs, now)
tue := p.Weekly[time.Tuesday]
if len(tue) != 2 {
t.Fatalf("got %d tuesday activities, want 2: %+v", len(tue), tue)
}
// Most-established first.
if tue[0].Key != "workout" || tue[0].Days != 4 {
t.Errorf("first = %+v, want workout on 4 days", tue[0])
}
if tue[0].TypicalAt != 19*time.Hour {
t.Errorf("typical at %v, want 19:00", tue[0].TypicalAt)
}
if len(p.Weekly[time.Wednesday]) != 0 {
t.Errorf("wednesday must be empty: %+v", p.Weekly[time.Wednesday])
}
if len(p.All) != 2 {
t.Errorf("the week-wide list should hold both: %+v", p.All)
}
}
// A one-off is not a habit. Saying "ты обычно X" off a single row is a
// confidently wrong claim about his life.
func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
now := behaviorNow()
obs := habitHistory("workout", time.Tuesday, 19, 0, 1, now)
// Three rows, same day — a busy Tuesday, not a habit.
obs = append(obs, Observation{At: obs[0].At.Add(time.Hour), Key: "workout", Kind: "self"})
obs = append(obs, Observation{At: obs[0].At.Add(2 * time.Hour), Key: "workout", Kind: "self"})
p := BuildProfile(obs, now)
if len(p.All) != 0 || len(p.Weekly) != 0 {
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
}
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
t.Errorf("empty profile reads %q", got)
}
}
// Only self-facts describe him. Env rows are the world and config rows are
// maven's own tuning state; counting either as a habit would be a category
// error the owner would then be told about.
func TestBuildProfileIgnoresNonSelfAndMachineryKeys(t *testing.T) {
now := behaviorNow()
var obs []Observation
for _, o := range habitHistory("water", time.Tuesday, 9, 0, 3, now) {
o.Kind = "env"
obs = append(obs, o)
}
for _, o := range habitHistory("cooldown:water", time.Tuesday, 9, 0, 3, now) {
obs = append(obs, o) // kind=self, but a machinery key
}
for _, o := range habitHistory("calendar_event_20260804_standup", time.Tuesday, 10, 0, 3, now) {
obs = append(obs, o)
}
if p := BuildProfile(obs, now); len(p.All) != 0 {
t.Fatalf("nothing here is a habit of his: %+v", p.All)
}
}
// The median, not the mean: one 03:00 outlier must not move a morning habit
// into the night.
func TestBuildProfileTypicalTimeIsMedian(t *testing.T) {
now := behaviorNow()
obs := habitHistory("water", time.Tuesday, 9, 0, 4, now)
obs = append(obs, Observation{At: obs[0].At.AddDate(0, 0, -28).Add(-6 * time.Hour), Key: "water", Kind: "self"})
p := BuildProfile(obs, now)
if len(p.All) != 1 {
t.Fatalf("got %+v", p.All)
}
if p.All[0].TypicalAt != 9*time.Hour {
t.Errorf("typical at %v, want 09:00 despite the outlier", p.All[0].TypicalAt)
}
}
func TestProfileFormatRUPersona(t *testing.T) {
now := behaviorNow()
obs := append(
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
habitHistory("water", time.Tuesday, 9, 5, 3, now)...,
)
p := BuildProfile(obs, now)
got := p.FormatWeekdayRU(time.Tuesday)
want := "по вторникам ты обычно тренируешься около 19:00 и пьёшь воду около 09:05."
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") {
t.Errorf("an unknown weekday reads %q", empty)
}
// Persona: she addresses him informally, never in the masculine about
// herself, and never with a pet name.
for _, s := range []string{got, p.FormatOverallRU(), p.FormatWeekdayRU(time.Thursday)} {
// Whole words: "ничего" contains "его", and a substring test would
// call a correct sentence a persona violation.
for _, tok := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r)
}) {
switch tok {
case "рад", "понял", "вы", "ваш", "ваши", "милый", "дорогой", "он", "его":
t.Errorf("%q uses %q", s, tok)
}
}
}
}
// An unrecognised key is read back verbatim rather than glossed into something
// maven made up.
func TestProfileUnknownKeyReadBackVerbatim(t *testing.T) {
now := behaviorNow()
p := BuildProfile(habitHistory("починил кран", time.Tuesday, 12, 0, 2, now), now)
if got := p.FormatOverallRU(); !strings.Contains(got, "починил кран") {
t.Errorf("got %q", got)
}
}
// A future-dated row is a clock problem, not a habit.
func TestBuildProfileIgnoresFutureRows(t *testing.T) {
now := behaviorNow()
obs := habitHistory("water", time.Tuesday, 9, 0, 3, now.AddDate(0, 2, 0))
if p := BuildProfile(obs, now); len(p.All) != 0 {
t.Fatalf("future rows counted: %+v", p.All)
}
}
// TestWeekdayProfileExcludesEverydayHabits — the "You drink water" case.
// Drinking water every day is not what he does on Saturdays, and answering
// with it makes the weekday question pointless.
func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
now := time.Date(2026, 8, 1, 20, 0, 0, 0, time.UTC) // a Saturday
var obs []Observation
// water: twice a day, every day, for three weeks.
for d := 1; d <= 21; d++ {
day := now.AddDate(0, 0, -d)
obs = append(obs,
Observation{At: time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, time.UTC), Key: "water", Kind: "self"},
Observation{At: time.Date(day.Year(), day.Month(), day.Day(), 18, 0, 0, 0, time.UTC), Key: "water", Kind: "self"})
}
// workout: Saturdays only.
for _, d := range []int{7, 14, 21} {
day := now.AddDate(0, 0, -d)
obs = append(obs, Observation{
At: time.Date(day.Year(), day.Month(), day.Day(), 11, 0, 0, 0, time.UTC), Key: "workout", Kind: "self"})
}
p := BuildProfile(obs, now)
sat := p.Weekly[time.Saturday]
if len(sat) != 1 || sat[0].Key != "workout" {
t.Fatalf("Saturday should be characterised by workout alone, got %+v", sat)
}
if len(p.Everyday) != 1 || p.Everyday[0].Key != "water" {
t.Fatalf("water should be an everyday habit, got %+v", p.Everyday)
}
got := p.FormatWeekdayRU(time.Saturday)
if !strings.Contains(got, "тренируешься") {
t.Fatalf("Saturday readout should name the workout: %q", got)
}
if strings.Contains(got, "воду") {
t.Fatalf("Saturday readout must not recite the everyday habit: %q", got)
}
// A day with nothing of its own says so rather than reciting water as if
// Wednesday were the reason for it.
wed := p.FormatWeekdayRU(time.Wednesday)
if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") {
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)
}
}
}