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.
This commit is contained in:
+142
-15
@@ -45,12 +45,19 @@ type Observation struct {
|
||||
//
|
||||
// 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.
|
||||
// воду утром" 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
|
||||
Key string
|
||||
Days int
|
||||
Count int
|
||||
TypicalAt time.Duration
|
||||
HasTypical bool
|
||||
}
|
||||
|
||||
// Profile — the counted behaviour model.
|
||||
@@ -82,13 +89,20 @@ 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_",
|
||||
"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.
|
||||
@@ -113,7 +127,7 @@ func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
if o.Kind != "self" {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(o.Key)
|
||||
key := canonicalize(o.Key)
|
||||
if key == "" || nonBehavioural(key) {
|
||||
continue
|
||||
}
|
||||
@@ -151,11 +165,13 @@ func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
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(medianInt(b.mins)) * time.Minute,
|
||||
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
|
||||
@@ -208,7 +224,24 @@ func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
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
|
||||
@@ -217,6 +250,63 @@ func nonBehavioural(key string) bool {
|
||||
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 {
|
||||
@@ -253,15 +343,44 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
||||
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||
day, joinActivities(p.Everyday))
|
||||
}
|
||||
return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day)
|
||||
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
|
||||
}
|
||||
|
||||
// FormatOverallRU reads back the habits that hold across the whole week.
|
||||
// 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.", joinActivities(p.All))
|
||||
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
|
||||
@@ -276,7 +395,15 @@ func joinActivities(acts []Activity) string {
|
||||
for i, a := range acts {
|
||||
gloss, ok := activityRU[a.Key]
|
||||
if !ok {
|
||||
gloss = a.Key
|
||||
// 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)
|
||||
|
||||
@@ -22,15 +22,18 @@ import (
|
||||
var behaviorRUJSON []byte
|
||||
|
||||
type behaviorRU struct {
|
||||
Weekdays []string `json:"weekdays"`
|
||||
Activities map[string]string `json:"activities"`
|
||||
Weekdays []string `json:"weekdays"`
|
||||
Activities map[string]string `json:"activities"`
|
||||
KeyAliases map[string][]string `json:"key_aliases"`
|
||||
}
|
||||
|
||||
var (
|
||||
// weekdayRU — dative plural, indexed by time.Weekday.
|
||||
weekdayRU []string
|
||||
// activityRU — fact key to second-person-singular verb phrase.
|
||||
// activityRU — canonical fact key to second-person-singular verb phrase.
|
||||
activityRU map[string]string
|
||||
// canonicalKey — observed fact key to the canonical key it counts as.
|
||||
canonicalKey map[string]string
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -42,4 +45,11 @@ func init() {
|
||||
panic(fmt.Sprintf("memory: behavior_ru.json: want 7 weekdays, got %d", len(v.Weekdays)))
|
||||
}
|
||||
weekdayRU, activityRU = v.Weekdays, v.Activities
|
||||
canonicalKey = make(map[string]string)
|
||||
for canonical, variants := range v.KeyAliases {
|
||||
canonicalKey[canonical] = canonical
|
||||
for _, variant := range variants {
|
||||
canonicalKey[variant] = canonical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
"plural, because both 'по вторникам' and 'по средам' need that form.",
|
||||
"",
|
||||
"activities glosses a fact key as a second-person-singular verb phrase. An",
|
||||
"unknown key is read back verbatim rather than guessed at: inventing a",
|
||||
"Russian phrase for a key maven does not recognise puts words in his mouth."
|
||||
"unknown key is not glossed and is quoted rather than guessed at: inventing",
|
||||
"a Russian phrase for a key maven does not recognise puts words in his",
|
||||
"mouth, and reading the raw key inline produces 'обычно ты выпил_воды'.",
|
||||
"",
|
||||
"key_aliases maps the fact keys the router actually emits onto the canonical",
|
||||
"key the profile counts. The key of a self fact comes straight out of the",
|
||||
"LLM with no allowlist behind it, so 'я выпил воду' and 'попил воды' arrive",
|
||||
"as different keys, split one habit into two, and drop both below the",
|
||||
"day threshold. Additive and lowercase; an unlisted key counts as itself."
|
||||
],
|
||||
"weekdays": [
|
||||
"воскресеньям",
|
||||
@@ -30,5 +37,15 @@
|
||||
"walk": "гуляешь",
|
||||
"pills": "пьёшь витамины",
|
||||
"workout": "тренируешься"
|
||||
},
|
||||
"key_aliases": {
|
||||
"water": ["вода", "воду", "воды", "водичка", "попил", "попил_воды", "выпил_воды", "выпил_воду", "drink_water", "drank_water"],
|
||||
"meal": ["еда", "еду", "поел", "поесть", "завтрак", "обед", "ужин", "food", "ate", "breakfast", "lunch", "dinner"],
|
||||
"sleep": ["сон", "спать", "лег", "лёг", "уснул", "заснул", "bedtime", "slept"],
|
||||
"break": ["перерыв", "отдых", "пауза", "rest", "pause"],
|
||||
"shower": ["душ", "принял_душ", "помылся", "мытьё", "мылся"],
|
||||
"walk": ["прогулка", "гулял", "погулял", "выгулял", "walked", "walking"],
|
||||
"pills": ["витамины", "таблетки", "таблетка", "лекарство", "vitamins", "meds", "medicine"],
|
||||
"workout": ["тренировка", "тренировался", "потренировался", "зал", "спорт", "exercise", "gym", "training"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,3 +207,146 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
|
||||
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypicalTimeIsCircular — the median is over a clock, not a number line.
|
||||
// Bedtimes either side of midnight used to average to midday, which is the
|
||||
// exact error the median was chosen to avoid, on the one activity most likely
|
||||
// to cross the boundary.
|
||||
func TestTypicalTimeIsCircular(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
var obs []Observation
|
||||
for i, mins := range []int{23*60 + 40, 23*60 + 50, 10, 20} {
|
||||
day := now.AddDate(0, 0, -(i + 1))
|
||||
obs = append(obs, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), 0, mins, 0, 0, now.Location()),
|
||||
Key: "sleep",
|
||||
Kind: "self",
|
||||
})
|
||||
}
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want one activity", p.All)
|
||||
}
|
||||
got := p.All[0].TypicalAt
|
||||
if !p.All[0].HasTypical {
|
||||
t.Fatal("a four-observation cluster has a typical time")
|
||||
}
|
||||
if got < 23*time.Hour+55*time.Minute && got > 5*time.Minute {
|
||||
t.Errorf("typical bedtime = %v, want just either side of midnight", got)
|
||||
}
|
||||
if s := p.FormatOverallRU(); strings.Contains(s, "около 12:00") {
|
||||
t.Errorf("read back as %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Times spread across the whole clock have no typical value, and she must not
|
||||
// name one.
|
||||
func TestNoTypicalTimeWhenSpreadWide(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
var obs []Observation
|
||||
for i, hh := range []int{2, 9, 16, 21} {
|
||||
day := now.AddDate(0, 0, -(i + 1))
|
||||
obs = append(obs, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
|
||||
Key: "water",
|
||||
Kind: "self",
|
||||
})
|
||||
}
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want one activity", p.All)
|
||||
}
|
||||
if p.All[0].HasTypical {
|
||||
t.Errorf("times spanning %v were given a typical value", p.All[0].TypicalAt)
|
||||
}
|
||||
if s := p.FormatOverallRU(); strings.Contains(s, "около") {
|
||||
t.Errorf("read back with a time she cannot support: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeysAreCanonicalisedBeforeCounting — the fact key comes out of the LLM
|
||||
// with no allowlist behind it, so the same habit arrives spelled several ways.
|
||||
// Counted separately, each spelling sits below MinHabitDays and the habit
|
||||
// vanishes.
|
||||
func TestKeysAreCanonicalisedBeforeCounting(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
var obs []Observation
|
||||
for i, key := range []string{"water", "Воду", "выпил воды", "попил_воды"} {
|
||||
day := now.AddDate(0, 0, -(i + 1))
|
||||
obs = append(obs, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, now.Location()),
|
||||
Key: key,
|
||||
Kind: "self",
|
||||
})
|
||||
}
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want one activity — the spellings are one habit", p.All)
|
||||
}
|
||||
if p.All[0].Key != "water" || p.All[0].Days != 4 {
|
||||
t.Errorf("got %+v, want water on 4 days", p.All[0])
|
||||
}
|
||||
if s := p.FormatOverallRU(); !strings.Contains(s, "пьёшь воду") {
|
||||
t.Errorf("read back as %q, want the glossed canonical key", s)
|
||||
}
|
||||
}
|
||||
|
||||
// An unglossed key is quoted, not read as a verb. "обычно ты выпил_воды около
|
||||
// 09:00" is what reciting the raw key produced.
|
||||
func TestUnglossedKeyIsQuoted(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
p := BuildProfile(habitHistory("починил_кран", time.Tuesday, 12, 0, 2, now), now)
|
||||
got := p.FormatOverallRU()
|
||||
if !strings.Contains(got, "отмечаешь «починил кран»") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// She says over what stretch of records "обычно" is claimed. Without it the
|
||||
// same sentence comes out of three days and out of a year.
|
||||
func TestOverallNamesThePeriod(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
p := BuildProfile(habitHistory("water", time.Tuesday, 9, 0, 3, now), now)
|
||||
got := p.FormatOverallRU()
|
||||
if !strings.Contains(got, "по записям за последние 21 день") {
|
||||
t.Errorf("got %q, want the period spoken", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The no-data weekday answer is about him, not about her. "у меня пока нет
|
||||
// ничего постоянного" answers a question nobody asked.
|
||||
func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) {
|
||||
p := BuildProfile(nil, behaviorNow())
|
||||
got := p.FormatWeekdayRU(time.Wednesday)
|
||||
if strings.Contains(got, "у меня") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "у тебя") {
|
||||
t.Errorf("got %q, want an answer about him", got)
|
||||
}
|
||||
}
|
||||
|
||||
// "quiet" is machinery, but only as a whole key. As a bare five-letter prefix
|
||||
// it silently swallowed any future self-fact key starting with those letters.
|
||||
func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
p := BuildProfile(habitHistory("quietude", time.Tuesday, 8, 0, 3, now), now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want the key counted", p.All)
|
||||
}
|
||||
p = BuildProfile(habitHistory("quiet_hours", time.Tuesday, 8, 0, 3, now), now)
|
||||
if len(p.All) != 0 {
|
||||
t.Fatalf("got %+v, want maven's own tuning state dropped", p.All)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralDaysRU(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
n int
|
||||
want string
|
||||
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
|
||||
if got := pluralDaysRU(c.n); got != c.want {
|
||||
t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user