Files
Maven/internal/memory/behavior.go
T
claude 42d7a39c49 morning, tasks, memory: say the summaries from the file (V-506)
The three callers now read their sentences out of summary_ru_v1.json: the
plan lines in morning.Plan.FormatRU, the list and reason words in
tasks.FormatRU, and the habit readouts in memory.Profile.

Two behaviour_test assertions moved from substring to say.IsS, because the
habit gaps have variants now and a substring pins one of them. The
"по {day} у тебя обычно" variant was dropped on sight: the activities are
verbs, so it read "у тебя обычно тренируешься".

The persona scorer covers the family, and a new test asserts every gap
variant still says she has not seen enough rather than that he has nothing.
2026-08-04 01:47:52 +04:00

450 lines
15 KiB
Go

package memory
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/say"
)
// 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 say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)})
}
if len(p.Everyday) > 0 {
return say.S(say.HabitWeekdaySame, map[string]string{
"day": day, "items": joinActivities(p.Everyday),
})
}
return say.S(say.HabitWeekdayNone, map[string]string{"day": 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 say.S(say.HabitWeekendBoth, map[string]string{
"sat": joinActivities(sat), "sun": joinActivities(sun),
})
case len(sat) > 0:
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
case len(sun) > 0:
return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
case len(p.Everyday) > 0:
return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
}
return say.S(say.HabitWeekendNone, nil)
}
// 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 say.S(say.HabitOverallNone, nil)
}
return say.S(say.HabitOverall, map[string]string{
"items": joinActivities(p.All), "span": 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 say.S(say.HabitSpanToday, nil)
}
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
return say.S(say.HabitSpanDays, map[string]string{
"n": strconv.Itoa(days), "word": 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 = say.S(say.HabitUnglossed, map[string]string{
"key": strings.ReplaceAll(a.Key, "_", " "),
})
}
if !a.HasTypical {
parts[i] = gloss
continue
}
parts[i] = say.S(say.HabitAt, map[string]string{
"gloss": gloss,
"time": fmt.Sprintf("%02d:%02d", 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]
}