Files
Maven/internal/memory/behavior.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

416 lines
14 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)
}
// 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]
}