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] }