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
+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)
}
}