memory: count habits over self facts only, and skip retracted ones

The behaviour profile read the newest 2000 rows of the shared facts table and
then discarded everything that was not kind=self, so the length of the window
was set by the noisiest writer. mavpoll writes a wg_handshake row every time a
peer rehandshakes, about every two minutes per peer, which is enough to reduce
2000 rows to under three days. A weekday habit needs two distinct Tuesdays, so
that window can never hold one, and she answered that she knows no habits on a
store holding a year of taps.

RecentActiveFactsByKind filters kind in SQL, and also drops rows a later row
voids along with the void marker itself. The old read counted both a retracted
tap and its retraction, so a fact he explicitly took back still shaped what she
said he usually does. A correction still counts, because a correction is a value
he stands behind.

Found in review of #59.
This commit is contained in:
kami
2026-08-01 14:00:46 +04:00
parent 88c841cb0e
commit 012bdcc1ae
9 changed files with 166 additions and 5 deletions
+13 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/rss"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/weather"
)
@@ -177,9 +178,19 @@ func isRestOfDayQuery(text string) bool {
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
}
// habitFactWindow — how many recent facts the behaviour profile is counted
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
// over. Enough for a season of habits without scanning the whole store on every
// question; the profile is recomputed on read, so the bound is the cost control.
//
// The read is kind-filtered in SQL, and that is the load-bearing part. When this
// was a plain recent-facts read the window was a row budget over every writer,
// and the machine writers dwarf the taps: mavpoll writes a wg_handshake row
// whenever a peer rehandshakes, which is roughly every two minutes per peer, so
// 2000 rows was under three days of history. A weekday habit needs
// memory.MinHabitDays distinct Tuesdays, which such a window can never hold, so
// she answered "по вторникам у меня пока нет ничего постоянного" forever on a
// store with a year of taps in it. Self facts come from voice taps, and he does
// not tap seven hundred times a day.
const habitFactWindow = 2000
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
@@ -190,7 +201,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
if !ok {
return "", false
}
facts, err := h.api.RecentFacts(ctx, habitFactWindow)
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
if err != nil {
log.Printf("voice: habits: recent facts: %v", err)
return "не получилось посмотреть записи.", true
+28 -3
View File
@@ -9,6 +9,7 @@ import (
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// planAPI answers only DayPlan; every other call is unimplemented, which is
@@ -142,17 +143,21 @@ func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
}
}
// habitAPI answers only RecentFacts — the whole input the behaviour profile
// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired.
// habitAPI answers only the kind-filtered fact read — the whole input the
// behaviour profile needs (Vikunja #254). Nothing is asked of the LLM, so
// nothing else is wired. RecentFacts is left unimplemented on purpose: the
// profile must not read the mixed window, and a caller that does fails here.
type habitAPI struct {
ipc.UnimplementedCoreAPI
facts []ipc.Fact
err error
calls int
kind string
}
func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
func (a *habitAPI) RecentActiveFactsByKind(_ context.Context, kind string, _ int) ([]ipc.Fact, error) {
a.calls++
a.kind = kind
return a.facts, a.err
}
@@ -225,3 +230,23 @@ func TestHabitSourcePrecedesCalendar(t *testing.T) {
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
}
}
// TestQueryHabitsReadsSelfFactsOnly — the profile window is a budget over rows,
// so it must be spent on the rows the profile can use. Reading the mixed table
// let one chatty poller (wg_handshake, roughly every two minutes per peer) push
// every tap out of the window, and she then reported no habits on a store that
// held them.
func TestQueryHabitsReadsSelfFactsOnly(t *testing.T) {
now := planDay()
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
if _, ok := h.queryHabits(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
}); !ok {
t.Fatal("the habit source must claim a habit question")
}
if api.kind != string(store.KindSelf) {
t.Errorf("profile read kind %q, want %q", api.kind, store.KindSelf)
}
}
+5
View File
@@ -439,6 +439,10 @@ type outcomesReq struct {
type nReq struct {
N int `json:"n"`
}
type kindNReq struct {
Kind string `json:"kind"`
N int `json:"n"`
}
type calendarEventsReq struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
@@ -586,6 +590,7 @@ type CoreAPI interface {
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
RecentFacts(ctx context.Context, n int) ([]Fact, error)
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
+9
View File
@@ -62,6 +62,7 @@ var readOnlyMethods = map[Method]bool{
MethodListReminders: true,
MethodRecentOutcomes: true,
MethodRecentFacts: true,
MethodRecentActiveFacts: true,
MethodCalendarEvents: true,
MethodRecentNudges: true,
MethodQueryNotes: true,
@@ -334,6 +335,14 @@ func (c *Client) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
return out, nil
}
func (c *Client) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
var out []Fact
if err := c.call(ctx, MethodRecentActiveFacts, kindNReq{Kind: kind, N: n}, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *Client) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
var out []Fact
if err := c.call(ctx, MethodCalendarEvents, calendarEventsReq{From: from, To: to}, &out); err != nil {
+22
View File
@@ -120,6 +120,18 @@ func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
return out, nil
}
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
fs, err := a.s.CalendarEvents(ctx, from, to)
if err != nil {
@@ -764,6 +776,16 @@ var methodTable = map[Method]handlerFunc{
}
return out, nil
}),
MethodRecentActiveFacts: withParams(func(ctx context.Context, api CoreAPI, p kindNReq) ([]Fact, error) {
out, err := api.RecentActiveFactsByKind(ctx, p.Kind, p.N)
if err != nil {
return nil, err
}
if out == nil {
out = []Fact{}
}
return out, nil
}),
MethodCalendarEvents: withParams(func(ctx context.Context, api CoreAPI, p calendarEventsReq) ([]Fact, error) {
out, err := api.CalendarEvents(ctx, p.From, p.To)
if err != nil {
+3
View File
@@ -62,6 +62,9 @@ func (UnimplementedCoreAPI) RecentOutcomes(ctx context.Context, rule string, n i
func (UnimplementedCoreAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
return nil, ErrNotImplemented
}
+1
View File
@@ -25,6 +25,7 @@ const (
MethodResolveNudge Method = "resolve_nudge"
MethodRecentOutcomes Method = "recent_outcomes"
MethodRecentFacts Method = "recent_facts"
MethodRecentActiveFacts Method = "recent_active_facts_by_kind"
MethodCalendarEvents Method = "calendar_events"
MethodRecentNudges Method = "recent_nudges"
MethodWriteNote Method = "write_note"
+39
View File
@@ -82,6 +82,45 @@ func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
return out, rows.Err()
}
// RecentActiveFactsByKind — the newest n facts of one kind, newest first, with
// the retracted ones left out. "Active" means two exclusions: a row some later
// row voids, and the void marker VoidLatestFact writes to retract it. A
// correction still counts, because a correction is a value he stands behind.
//
// It exists because the facts table is shared and the noisy writers are not the
// interesting ones. A caller that reads n recent rows and then keeps the self
// ones has a window whose real length is set by how often the pollers write:
// one WireGuard peer alone rehandshakes every couple of minutes, which is
// enough env rows to reduce a 2000-row window to under three days. Filtering in
// SQL makes the bound mean what the caller thinks it means.
//
// Voided rows are excluded here and included by RecentFacts on purpose. The
// dash shows the audit trail, because you want to SEE a correction. A reader
// that asks what he usually does must not count something he explicitly took
// back.
func (s *Store) RecentActiveFactsByKind(ctx context.Context, kind FactKind, n int) ([]Fact, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts
WHERE kind = ?
AND NOT (voids_id IS NOT NULL AND value = '"voided"')
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC LIMIT ?`, string(kind), n)
if err != nil {
return nil, fmt.Errorf("recent facts by kind: %w", err)
}
defer rows.Close()
var out []Fact
for rows.Next() {
f, err := scanFact(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// CalendarEvents returns calendar facts whose key date falls within [from, to).
// Calendar event keys have the format calendar_event_YYYYMMDD_<summary>.
//
+46
View File
@@ -424,3 +424,49 @@ func TestCalendarEventsIncludesAmbientSource(t *testing.T) {
t.Error("a non-calendar source must not be read as a calendar event")
}
}
// TestRecentActiveFactsByKind — the behaviour profile's window. Env rows must
// not spend it, and a retracted row must not be counted as something he did.
func TestRecentActiveFactsByKind(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
// A noisy env writer, the way mavpoll writes handshakes.
for i := 0; i < 50; i++ {
if _, err := s.WriteFact(ctx, now.Add(-time.Duration(i)*time.Minute), KindEnv,
"wg_handshake", "1", "poll:wireguard", 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("write env fact: %v", err)
}
}
if _, err := s.WriteFact(ctx, now.Add(-2*time.Hour), KindSelf, "workout", "done", "tap:voice", 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("write self fact: %v", err)
}
if _, err := s.WriteFact(ctx, now.Add(-3*time.Hour), KindSelf, "walk", "done", "tap:voice", 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("write self fact: %v", err)
}
if _, _, err := s.VoidLatestFact(ctx, "walk", "tap:voice", now.Add(-time.Hour)); err != nil {
t.Fatalf("VoidLatestFact: %v", err)
}
got, err := s.RecentActiveFactsByKind(ctx, KindSelf, 10)
if err != nil {
t.Fatalf("RecentActiveFactsByKind: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d facts, want 1 (workout): %+v", len(got), got)
}
if got[0].Key != "workout" {
t.Errorf("key = %q, want workout", got[0].Key)
}
// The window is spent on self rows only: a limit smaller than the env
// traffic still returns the taps.
got, err = s.RecentActiveFactsByKind(ctx, KindSelf, 1)
if err != nil {
t.Fatalf("RecentActiveFactsByKind: %v", err)
}
if len(got) != 1 || got[0].Key != "workout" {
t.Fatalf("got %+v, want the newest self fact", got)
}
}