Answer "что я обычно делаю по вторникам?" by counting, not guessing (#254) #59

Closed
claude wants to merge 1 commits from overnight/behavior-profile into overnight/day-plan
Contributor

What

Behavioural memory, narrowed to the half that can be true.

  • internal/memory/behavior.goBuildProfile counts habits out of self-facts: distinct days per weekday (MinHabitDays = 2), a median time of day, and FormatWeekdayRU / FormatOverallRU for the spoken answer.
  • internal/router/habit.goParseHabitQuery: requires a habit marker ("обычно", "каждую", "привычки", …) and resolves the weekday deterministically.
  • cmd/mavend/actions_query.go — a habits query source, placed before calendar.
  • docs/plans/09-behavioral-memory.md — a status section recording what shipped and what was refused.

No new IPC method: the counted profile reads RecentFacts over the existing surface.

Why this is smaller than the plan doc

Three of the doc's steps are deliberately not implemented, and I would argue against implementing them as written:

  • Step 3, LLM-generated profile stored as a fact. The profile is counted, not generated. A 1.7B asked to summarise a year of habits produces fluent claims about the owner's life that no row in the store supports. A wrong claim about him is the most expensive kind of wrong maven can be; counting distinct days is verifiable and cheap enough to run on the question.
  • Step 5, incremental update on fact write. There is no cache to keep fresh. The profile is recomputed on the question, so a new fact is already in the next answer, and a cached profile that can disagree with its own rows is two truths.
  • Step 4, proactive daily plan proposals via the dispatcher. Maven is not a nag, and a nudge at 08:00 every day proposing the day is the definition of one. The sanctioned path from "she noticed a pattern" to "she acts on it" already exists — internal/pattern/detector.go proposes a routine and the owner accepts it on /routines. It goes through him.

Still open if wanted: MethodGetBehaviorProfile + a /dash panel (step 7). That is a display concern only.

Judgement calls worth reviewing

  • Two distinct days before she calls something usual. Below that she says she has not got enough records, which is true. Three rows on one busy Tuesday is not a habit.
  • Only self-facts. Env rows are the world (weather, a relayed meeting), config rows are her own tuning state; neither says anything about what he does. Calendar-event and cooldown:/quiet keys are excluded explicitly.
  • Median, not mean time of day, so one 03:00 outlier cannot move a morning habit into the night.
  • An unrecognised fact key is read back verbatim rather than glossed. Inventing a Russian phrase for a key she does not recognise is putting words in his mouth.
  • The matcher requires a habit marker, so "что я делаю в среду?" still reaches the calendar. Answering a question about this coming Wednesday with a statistical average answers a different question.

How verified

make build and make test both exit 0.

New tests: weekday counting and ordering, the one-off rejection, env/config/machinery-key exclusion, median-vs-outlier, future-row rejection, the exact RU rendering scanned word-by-word against the persona rules, verbatim fallback for unknown keys, the matcher's yes/no set, and the query source's claim/pass behaviour plus its ordering before calendar.

Vikunja #254

## What Behavioural memory, narrowed to the half that can be true. - `internal/memory/behavior.go` — `BuildProfile` counts habits out of self-facts: distinct days per weekday (`MinHabitDays = 2`), a median time of day, and `FormatWeekdayRU` / `FormatOverallRU` for the spoken answer. - `internal/router/habit.go` — `ParseHabitQuery`: requires a habit marker ("обычно", "каждую", "привычки", …) and resolves the weekday deterministically. - `cmd/mavend/actions_query.go` — a `habits` query source, placed before `calendar`. - `docs/plans/09-behavioral-memory.md` — a status section recording what shipped and what was refused. No new IPC method: the counted profile reads `RecentFacts` over the existing surface. ## Why this is smaller than the plan doc Three of the doc's steps are deliberately not implemented, and I would argue against implementing them as written: - **Step 3, LLM-generated profile stored as a fact.** The profile is counted, not generated. A 1.7B asked to summarise a year of habits produces fluent claims about the owner's life that no row in the store supports. A wrong claim *about him* is the most expensive kind of wrong maven can be; counting distinct days is verifiable and cheap enough to run on the question. - **Step 5, incremental update on fact write.** There is no cache to keep fresh. The profile is recomputed on the question, so a new fact is already in the next answer, and a cached profile that can disagree with its own rows is two truths. - **Step 4, proactive daily plan proposals via the dispatcher.** Maven is not a nag, and a nudge at 08:00 every day proposing the day is the definition of one. The sanctioned path from "she noticed a pattern" to "she acts on it" already exists — `internal/pattern/detector.go` proposes a routine and the owner accepts it on `/routines`. It goes through him. Still open if wanted: `MethodGetBehaviorProfile` + a `/dash` panel (step 7). That is a display concern only. ## Judgement calls worth reviewing - **Two distinct days** before she calls something usual. Below that she says she has not got enough records, which is true. Three rows on one busy Tuesday is not a habit. - **Only self-facts.** Env rows are the world (weather, a relayed meeting), config rows are her own tuning state; neither says anything about what he does. Calendar-event and `cooldown:`/`quiet` keys are excluded explicitly. - **Median, not mean** time of day, so one 03:00 outlier cannot move a morning habit into the night. - **An unrecognised fact key is read back verbatim** rather than glossed. Inventing a Russian phrase for a key she does not recognise is putting words in his mouth. - **The matcher requires a habit marker**, so "что я делаю в среду?" still reaches the calendar. Answering a question about this coming Wednesday with a statistical average answers a different question. ## How verified `make build` and `make test` both exit 0. New tests: weekday counting and ordering, the one-off rejection, env/config/machinery-key exclusion, median-vs-outlier, future-row rejection, the exact RU rendering scanned word-by-word against the persona rules, verbatim fallback for unknown keys, the matcher's yes/no set, and the query source's claim/pass behaviour plus its ordering before `calendar`. Vikunja #254
claude added 1 commit 2026-08-01 00:21:35 +02:00
Behavioural memory, narrowed on purpose. internal/memory/behavior.go builds a
profile out of self-facts — distinct days per weekday, median time of day — and
reads it back in RU; router.ParseHabitQuery finds the weekday deterministically;
a `habits` query source answers the question.

Three things the plan doc asks for are deliberately absent, and the doc now
records why:

- The profile is COUNTED, not LLM-generated. A 1.7B asked to summarise a year of
  habits writes fluent claims about the owner's life that no row supports, and a
  wrong claim about him is the most expensive kind of wrong maven can be.
- No cached profile fact, so no "update on fact write" machinery. It is
  recomputed on the question; a cache that can disagree with its own rows is two
  truths.
- No proactive daily plan nudge. A dispatcher proposal at 08:00 every day is the
  definition of a nag. The path from "she noticed a pattern" to "she acts on it"
  already exists in internal/pattern with the proposal queue on /routines, and it
  goes through him.

A one-off is not a habit: an activity needs two distinct days before she will
call it usual, and until then she says she does not know yet. Only self-facts
count — env rows are the world, config rows are her own tuning state. The typical
time is a median so one 03:00 outlier cannot move a morning habit into the night.
An unrecognised fact key is read back verbatim rather than glossed into something
she made up.

The source sits before "calendar" in querySources, and its matcher requires a
habit marker, so "что я делаю в среду?" still reaches the calendar — answering a
question about this coming Wednesday with a statistical average would be
answering a different question.

Verified: make build and make test both exit 0.
kami reviewed 2026-08-01 10:31:06 +02:00
@@ -0,0 +200,4 @@
// activityRU glosses the loop's known fact keys. An unknown key is read back
// verbatim: it is what the store holds, and inventing a Russian phrase for a key
// maven does not recognise would be putting words in his mouth.
var activityRU = map[string]string{
Owner

what?
"Maven, what do I do on Saturdays? - You drink water." - is this how it's supposed to work?

what? "Maven, what do I do on Saturdays? - You drink water." - is this how it's supposed to work?
Owner

and here's another thing which my eyes caught - a lot of the strings can either be moved somewhere like a constants store. or even be extracted in separate files: yaml, toml, json, whatever.

and here's another thing which my eyes caught - a lot of the strings can either be moved somewhere like a constants store. or even be extracted in separate files: yaml, toml, json, whatever.
claude reviewed 2026-08-01 11:32:52 +02:00
claude left a comment
Author
Contributor

Counting instead of generating is the right call, and the header comment argues it well. Refusing step 4 of docs/plans/09-behavioral-memory.md in the same file that implements the rest is the strongest thing in this PR. Distinct days rather than raw count is the correct habit unit. The median time keeps one 03:00 outlier out of the answer. ParseHabitQuery requiring a habit marker before it claims the turn is the right seam against queryCalendar.

7f42cc7 already answers your everyday-habits comment. Profile.Everyday, EverydaySpan, and the reworked FormatWeekdayRU are on the tip, and the RU tables are in behavior_ru.json. Nothing below is fixed there.

1. The 2000-fact window is a row budget over all kinds, and machine rows eat it.

RecentFacts is ORDER BY ts DESC LIMIT ? with no kind filter. BuildProfile then throws away everything that is not kind=self, but the discarding happens after the limit, not before. So the size of the behavioural window depends on how noisy the other writers are.

mavpoll polls every 60s and writes wg_handshake whenever the handshake timestamp is newer than the stored one. WireGuard rehandshakes about every two minutes per active peer. That is roughly 700 env rows a day from one peer, before netdata, kuma, ambient events or telegram.

At that rate 2000 rows is under three days. A weekday habit needs MinHabitDays distinct Tuesdays, so at least eight days of history. The window can never contain two Tuesdays. FormatWeekdayRU answers "по вторникам у меня пока нет ничего постоянного" forever, on a store that holds a year of taps.

The comment calls the bound "the cost control", but it is not costing what it thinks. Add WHERE kind = 'self' to the query behind this call, or bound the window by time and let the row count be whatever it is. A self-fact-only scan over 90 days is small. Self facts come from voice taps, and he does not tap 700 times a day.

2. The counting unit is a key the model invented.

Activity.Key is Slots.Key, which comes straight out of the LLM router. routeSystem shows exactly one keyed example, {"intent":"fact","key":"water","value":"выпил"}, and the GBNF constrains field names only. There is no key allowlist and no normalization anywhere in the tree.

Two consequences, both visible in the answer. First, counts split. "я выпил воду" on Monday and "попил воды" on Tuesday can land as water and воду. Each has one day, each is below MinHabitDays, and the habit disappears. Second, activityRU glosses eight English keys, and everything else is recited verbatim. A key of выпил_воды produces "обычно ты выпил_воды около 09:00". That is a snake-cased past-tense verb read aloud mid-sentence.

internal/pattern already solved this one layer over: it extracts a normalized action plus object rather than trusting the key. The profile should count those same normalized events. Failing that, the fact write should map the key through a canonical table.

3. Any habit question phrased with "планы" is swallowed by the day plan.

querySources puts day-plan before habits. IsDayPlanQuery fires on the token "планы" and its otherDayWords list does not know weekday names.

"какие у меня обычно планы по вторникам?" has a habit marker and a weekday. queryDayPlan claims it first and answers today's calendar, stamped with today's date. queryHabits never runs. The comment you added to querySources says the habit question is "the more specific one", and the ordering says the opposite.

Either move habits above day-plan, or make IsDayPlanQuery return false when ParseHabitQuery matches. The second is better: one matcher should not have to know the other's position in a slice.

4. medianInt is a median of minutes-since-midnight, so it breaks on anything that straddles midnight.

sleep is in the gloss table. Bedtimes of 23:40, 23:50, 00:10 and 00:20 give minute values of 1420, 1430, 10 and 20. The median is 720. She reports "обычно ты спишь около 12:00".

This is the exact class of error the median was chosen to avoid. It hits the one activity most likely to cross the boundary. A circular median, or a per-activity day-cut offset, would fix it. Refusing to report a time when the values span more than twelve hours would also be honest.

Smaller notes:

  • RecentFacts includes voided rows, by its own doc comment: "you want to SEE a correction, not have it hidden". The profile counts both the retracted row and the correction. A fact he explicitly reverted still shapes what she says he usually does.
  • Profile.Since is computed and never spoken. She says "обычно" without ever saying over what period. Given finding 1 that period may be three days. One clause on the end of FormatOverallRU would make the claim checkable.
  • The no-data branch of FormatWeekdayRU still says "по средам у меня пока нет ничего постоянного". "У меня" is her, and the question was about him. The tip changed the other two branches and left this one.
  • nonBehaviouralKeyPrefixes holds "quiet" as a bare prefix, not "quiet_" or an exact match. Any future self-fact key starting with those five letters is silently dropped from behaviour.
  • weekdayWords has no "воскресенье" oblique forms beyond the dative plural, and no "по выходным". "что я обычно делаю по выходным?" matches the marker and finds no weekday. She answers with the whole-week profile instead.
Counting instead of generating is the right call, and the header comment argues it well. Refusing step 4 of `docs/plans/09-behavioral-memory.md` in the same file that implements the rest is the strongest thing in this PR. Distinct days rather than raw count is the correct habit unit. The median time keeps one 03:00 outlier out of the answer. `ParseHabitQuery` requiring a habit marker before it claims the turn is the right seam against `queryCalendar`. `7f42cc7` already answers your everyday-habits comment. `Profile.Everyday`, `EverydaySpan`, and the reworked `FormatWeekdayRU` are on the tip, and the RU tables are in `behavior_ru.json`. Nothing below is fixed there. **1. The 2000-fact window is a row budget over all kinds, and machine rows eat it.** `RecentFacts` is `ORDER BY ts DESC LIMIT ?` with no kind filter. `BuildProfile` then throws away everything that is not `kind=self`, but the discarding happens after the limit, not before. So the size of the behavioural window depends on how noisy the other writers are. `mavpoll` polls every 60s and writes `wg_handshake` whenever the handshake timestamp is newer than the stored one. WireGuard rehandshakes about every two minutes per active peer. That is roughly 700 env rows a day from one peer, before netdata, kuma, ambient events or telegram. At that rate 2000 rows is under three days. A weekday habit needs `MinHabitDays` distinct Tuesdays, so at least eight days of history. The window can never contain two Tuesdays. `FormatWeekdayRU` answers "по вторникам у меня пока нет ничего постоянного" forever, on a store that holds a year of taps. The comment calls the bound "the cost control", but it is not costing what it thinks. Add `WHERE kind = 'self'` to the query behind this call, or bound the window by time and let the row count be whatever it is. A self-fact-only scan over 90 days is small. Self facts come from voice taps, and he does not tap 700 times a day. **2. The counting unit is a key the model invented.** `Activity.Key` is `Slots.Key`, which comes straight out of the LLM router. `routeSystem` shows exactly one keyed example, `{"intent":"fact","key":"water","value":"выпил"}`, and the GBNF constrains field names only. There is no key allowlist and no normalization anywhere in the tree. Two consequences, both visible in the answer. First, counts split. "я выпил воду" on Monday and "попил воды" on Tuesday can land as `water` and `воду`. Each has one day, each is below `MinHabitDays`, and the habit disappears. Second, `activityRU` glosses eight English keys, and everything else is recited verbatim. A key of `выпил_воды` produces "обычно ты выпил_воды около 09:00". That is a snake-cased past-tense verb read aloud mid-sentence. `internal/pattern` already solved this one layer over: it extracts a normalized action plus object rather than trusting the key. The profile should count those same normalized events. Failing that, the fact write should map the key through a canonical table. **3. Any habit question phrased with "планы" is swallowed by the day plan.** `querySources` puts `day-plan` before `habits`. `IsDayPlanQuery` fires on the token "планы" and its `otherDayWords` list does not know weekday names. "какие у меня обычно планы по вторникам?" has a habit marker and a weekday. `queryDayPlan` claims it first and answers today's calendar, stamped with today's date. `queryHabits` never runs. The comment you added to `querySources` says the habit question is "the more specific one", and the ordering says the opposite. Either move `habits` above `day-plan`, or make `IsDayPlanQuery` return false when `ParseHabitQuery` matches. The second is better: one matcher should not have to know the other's position in a slice. **4. `medianInt` is a median of minutes-since-midnight, so it breaks on anything that straddles midnight.** `sleep` is in the gloss table. Bedtimes of 23:40, 23:50, 00:10 and 00:20 give minute values of 1420, 1430, 10 and 20. The median is 720. She reports "обычно ты спишь около 12:00". This is the exact class of error the median was chosen to avoid. It hits the one activity most likely to cross the boundary. A circular median, or a per-activity day-cut offset, would fix it. Refusing to report a time when the values span more than twelve hours would also be honest. Smaller notes: - `RecentFacts` includes voided rows, by its own doc comment: "you want to SEE a correction, not have it hidden". The profile counts both the retracted row and the correction. A fact he explicitly reverted still shapes what she says he usually does. - `Profile.Since` is computed and never spoken. She says "обычно" without ever saying over what period. Given finding 1 that period may be three days. One clause on the end of `FormatOverallRU` would make the claim checkable. - The no-data branch of `FormatWeekdayRU` still says "по средам у меня пока нет ничего постоянного". "У меня" is her, and the question was about him. The tip changed the other two branches and left this one. - `nonBehaviouralKeyPrefixes` holds `"quiet"` as a bare prefix, not `"quiet_"` or an exact match. Any future self-fact key starting with those five letters is silently dropped from behaviour. - `weekdayWords` has no "воскресенье" oblique forms beyond the dative plural, and no "по выходным". "что я обычно делаю по выходным?" matches the marker and finds no weekday. She answers with the whole-week profile instead.
kami closed this pull request 2026-08-01 14:51:44 +02:00
Owner

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kami/Maven#59