Files
Maven/internal/memory/behavior.go
kami c21d8fdcee router: let a habit question outrank the day plan, and know the weekend
IsDayPlanQuery fires on the token "планы" and its other-day list does not know
weekday names, so "какие у меня обычно планы по вторникам?" was claimed by the
day plan, which answered today's calendar stamped with today's date. The habit
source never ran. The matcher now declines any utterance ParseHabitQuery
claims, which keeps the decision out of the source table's ordering.

Two gaps in the same matcher. Sunday had only its dative plural listed, so "в
воскресенье" found no weekday. "по выходным" named days that no weekday word
matches, so it was answered with the whole-week profile. Both are recognised
now, and the weekend is read back as two days rather than pooled.

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

440 lines
15 KiB
Go

package memory
import (
"fmt"
"sort"
"strings"
"time"
)
// Behavioural memory — "what do I usually do?" (Vikunja #254).
//
// The profile is COUNTED, not generated. docs/plans/09-behavioral-memory.md
// asks for an LLM to write a behaviour profile daily and store it as a fact;
// this does not do that, on purpose. A 1.7B asked to summarise a year of habits
// will produce fluent claims about the owner's life that no row in the store
// supports, and a wrong claim about him is the most expensive kind of wrong
// maven can be. Counting distinct days per weekday is verifiable, cheap enough
// to run on the question, and cannot invent a habit he does not have.
//
// Recomputed on read rather than cached as a fact for the same reason the store
// is append-only: a cached profile can disagree with the rows it came from, and
// then there are two truths. The plan's step 5 ("profile updates on fact write")
// exists to keep a cache fresh; there is no cache, so a new fact is already in
// the next answer.
//
// It is also read-only and unprompted-free. The plan's step 4 — a morning
// dispatcher nudge proposing the day — is deliberately NOT here: maven is not a
// nag, and proposing plans at 08:00 every day is the definition of one. Pattern
// inference that leads to a routine the owner accepts already exists in
// internal/pattern with the proposal queue on /routines; that is the sanctioned
// path from "she noticed" to "she acts", and it goes through him.
// Observation — one thing the owner was recorded doing, reduced to what a habit
// needs: when, and what. Facts arrive as store/ipc rows; the caller maps them
// so this package stays free of both.
type Observation struct {
At time.Time
Key string
Kind string // "self" | "env" | "config"
}
// Activity — one recurring thing, as counted. Days is the number of DISTINCT
// days it was observed on, which is the number that decides whether something
// is a habit; Count can be inflated by one busy day.
//
// 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. 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
HasTypical bool
}
// Profile — the counted behaviour model.
//
// Weekly holds only the activities that DISTINGUISH a weekday: things he does
// on Tuesdays and not on most other days. Everyday holds the ones that recur
// across the week, and All holds both. The split exists because the two answer
// different questions, and conflating them produced the failure that named
// this: asked what he does on Saturdays, maven replied "ты пьёшь воду".
type Profile struct {
Since time.Time
Until time.Time
Weekly map[time.Weekday][]Activity
Everyday []Activity
All []Activity
}
// EverydaySpan — the number of weekdays an activity must be a habit on before
// it stops counting as characteristic of any one of them. Six of seven, not
// five: a weekday-only rhythm spans exactly five, and "по будням ты
// тренируешься" is a real answer about Tuesday. Six days a week is not.
const EverydaySpan = 6
// MinHabitDays — how many distinct days an activity must appear on before maven
// will call it usual. Two is the smallest number that can distinguish a habit
// from a one-off; below that she says she does not know yet, which is true.
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:",
"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.
//
// Only self-facts count. An env row is the world (weather, a relayed meeting),
// and a config row is maven's own state; neither says anything about what he
// usually does.
func BuildProfile(obs []Observation, now time.Time) Profile {
loc := now.Location()
p := Profile{Until: now, Weekly: map[time.Weekday][]Activity{}}
type bucket struct {
days map[string]struct{}
count int
mins []int
}
// key → bucket, and (weekday, key) → bucket.
all := map[string]*bucket{}
weekly := map[time.Weekday]map[string]*bucket{}
for _, o := range obs {
if o.Kind != "self" {
continue
}
key := canonicalize(o.Key)
if key == "" || nonBehavioural(key) {
continue
}
at := o.At.In(loc)
if at.IsZero() || at.After(now) {
continue
}
if p.Since.IsZero() || at.Before(p.Since) {
p.Since = at
}
day := at.Format("2006-01-02")
minute := at.Hour()*60 + at.Minute()
bump := func(m map[string]*bucket) {
b := m[key]
if b == nil {
b = &bucket{days: map[string]struct{}{}}
m[key] = b
}
b.days[day] = struct{}{}
b.count++
b.mins = append(b.mins, minute)
}
bump(all)
wd := at.Weekday()
if weekly[wd] == nil {
weekly[wd] = map[string]*bucket{}
}
bump(weekly[wd])
}
harvest := func(m map[string]*bucket) []Activity {
var out []Activity
for key, b := range m {
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(mid) * time.Minute,
HasTypical: known,
})
}
// Most-established first, then earliest in the day, then by key so the
// same history always reads back the same way.
sort.Slice(out, func(i, j int) bool {
if out[i].Days != out[j].Days {
return out[i].Days > out[j].Days
}
if out[i].TypicalAt != out[j].TypicalAt {
return out[i].TypicalAt < out[j].TypicalAt
}
return out[i].Key < out[j].Key
})
return out
}
p.All = harvest(all)
// How many weekdays each key is a habit on. An activity that recurs on
// most days of the week is a daily habit, and naming it as an answer to
// "что я обычно делаю по субботам?" is a non-answer: "ты пьёшь воду" is
// true of Saturday and of every other day, so it says nothing about
// Saturday. Those are held in Everyday and read back separately.
span := map[string]int{}
harvested := map[time.Weekday][]Activity{}
for wd, m := range weekly {
acts := harvest(m)
harvested[wd] = acts
for _, a := range acts {
span[a.Key]++
}
}
for wd, acts := range harvested {
var distinct []Activity
for _, a := range acts {
if span[a.Key] >= EverydaySpan {
continue
}
distinct = append(distinct, a)
}
if len(distinct) > 0 {
p.Weekly[wd] = distinct
}
}
for _, a := range p.All {
if span[a.Key] >= EverydaySpan {
p.Everyday = append(p.Everyday, a)
}
}
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
}
}
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 {
return 0
}
s := make([]int, len(xs))
copy(s, xs)
sort.Ints(s)
mid := len(s) / 2
if len(s)%2 == 1 {
return s[mid]
}
return (s[mid-1] + s[mid]) / 2
}
// weekdayRU and activityRU are loaded from the embedded behavior_ru.json;
// see behavior_ru.go.
// FormatWeekdayRU reads back what DISTINGUISHES a given weekday.
// Second person singular and informal, as she speaks TO him.
//
// When nothing distinguishes it, she says so and names the daily habits as
// daily habits instead of passing them off as an answer about that day. The
// previous version had no such distinction and answered "что я делаю по
// субботам?" with "ты пьёшь воду" — true, useless, and phrased as if Saturday
// were the reason.
func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
day := weekdayRU[int(wd)%7]
acts := p.Weekly[wd]
if len(acts) > 0 {
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
}
if len(p.Everyday) > 0 {
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
day, joinActivities(p.Everyday))
}
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
}
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
//
// The two days are answered separately rather than pooled: "по выходным" is a
// question about both, and a habit he has on Saturdays only is the interesting
// half of the answer, not noise to average away.
func (p Profile) FormatWeekendRU() string {
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
switch {
case len(sat) > 0 && len(sun) > 0:
return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
joinActivities(sat), joinActivities(sun))
case len(sat) > 0:
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
joinActivities(sat))
case len(sun) > 0:
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
joinActivities(sun))
case len(p.Everyday) > 0:
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
joinActivities(p.Everyday))
}
return "по выходным я пока не вижу у тебя ничего постоянного."
}
// 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 — %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
// not an answer; the most established few are.
const maxRecited = 5
func joinActivities(acts []Activity) string {
if len(acts) > maxRecited {
acts = acts[:maxRecited]
}
parts := make([]string, len(acts))
for i, a := range acts {
gloss, ok := activityRU[a.Key]
if !ok {
// 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)
}
if len(parts) == 1 {
return parts[0]
}
return strings.Join(parts[:len(parts)-1], ", ") + " и " + parts[len(parts)-1]
}