c8444813e2
Behavioural memory, narrowed on purpose. internal/memory/behavior.go builds a profile out of self-facts — distinct days per weekday, median time of day — and reads it back in RU; router.ParseHabitQuery finds the weekday deterministically; a `habits` query source answers the question. Three things the plan doc asks for are deliberately absent, and the doc now records why: - The profile is COUNTED, not LLM-generated. A 1.7B asked to summarise a year of habits writes fluent claims about the owner's life that no row supports, and a wrong claim about him is the most expensive kind of wrong maven can be. - No cached profile fact, so no "update on fact write" machinery. It is recomputed on the question; a cache that can disagree with its own rows is two truths. - No proactive daily plan nudge. A dispatcher proposal at 08:00 every day is the definition of a nag. The path from "she noticed a pattern" to "she acts on it" already exists in internal/pattern with the proposal queue on /routines, and it goes through him. A one-off is not a habit: an activity needs two distinct days before she will call it usual, and until then she says she does not know yet. Only self-facts count — env rows are the world, config rows are her own tuning state. The typical time is a median so one 03:00 outlier cannot move a morning habit into the night. An unrecognised fact key is read back verbatim rather than glossed into something she made up. The source sits before "calendar" in querySources, and its matcher requires a habit marker, so "что я делаю в среду?" still reaches the calendar — answering a question about this coming Wednesday with a statistical average would be answering a different question. Verified: make build and make test both exit 0.
164 lines
5.5 KiB
Go
164 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|