Files
Maven/internal/store/facts.go
T
kami 012bdcc1ae 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.
2026-08-01 14:00:46 +04:00

316 lines
11 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/kami/maven/internal/calendar"
)
// WriteFact appends a fact row. confidence must be 1.0 for taps and (0,1) for
// inferences; the caller is responsible for that provenance discipline.
//
// If voidsID is Valid, this row voids (cancels) the referenced fact. The
// caller must have verified voidsID points at an existing fact — we check it
// here too and refuse to write a dangling voids pointer (the audit trail must
// stay coherent).
func (s *Store) WriteFact(ctx context.Context, ts time.Time, kind FactKind, key, value, source string, confidence float64, voidsID sql.NullInt64) (int64, error) {
if confidence <= 0.0 || confidence > 1.0 {
return 0, fmt.Errorf("%w: %f", ErrConfidence, confidence)
}
if voidsID.Valid {
var ok int64
err := s.db.QueryRowContext(ctx, "SELECT 1 FROM facts WHERE id = ?", voidsID.Int64).Scan(&ok)
if errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf("%w: id=%d", ErrVoidsMissing, voidsID.Int64)
}
if err != nil {
return 0, fmt.Errorf("voids lookup: %w", err)
}
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
ts.UnixMilli(), string(kind), key, value, source, confidence, voidsID)
if err != nil {
return 0, fmt.Errorf("write fact: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("last insert id: %w", err)
}
return id, nil
}
// LatestFact returns the latest non-voided fact for key, or ErrNoFact.
// "Non-voided" = no later row has voids_id pointing at it. We resolve this by
// taking the newest row whose id is not referenced by any voids_id.
func (s *Store) LatestFact(ctx context.Context, key string) (Fact, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts
WHERE key = ?
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC
LIMIT 1`, key)
return scanFact(row)
}
// RecentFacts — the newest n facts across all keys, for the monitoring dash.
// Includes voided rows (the audit trail is the point: you want to SEE a
// correction, not have it hidden). Newest first.
func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts ORDER BY ts DESC, id DESC LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("recent facts: %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()
}
// 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>.
//
// Every calendar source is included, not just the personal CalDAV poll: the work
// calendar arrives as ambient:notif notifications (Vikunja #126) and belongs in
// the same answer. The source stays on each Fact, along with its confidence, so
// the caller can hedge a reading it did not get from a calendar server —
// filtering by source here would have thrown that judgement away.
func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
prefixFrom := calendar.KeyPrefixForDay(from)
prefixTo := calendar.KeyPrefixForDay(to)
sources := calendar.Sources()
args := make([]any, 0, len(sources)+2)
for _, src := range sources {
args = append(args, src)
}
args = append(args, prefixFrom, prefixTo)
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts
WHERE source IN (`+placeholders(len(sources))+`)
AND key >= ? AND key < ?
ORDER BY key`, args...)
if err != nil {
return nil, fmt.Errorf("calendar events: %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()
}
// LatestFactBySource — provenance-scoped. A rule on `service_down` trusts only
// source=poll:healthcheck; a compromised poller can't forge a trigger. Use this
// from rules, not LatestFact, whenever the rule's source contract matters.
func (s *Store) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts
WHERE key = ? AND source = ?
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC
LIMIT 1`, key, source)
return scanFact(row)
}
// Since returns how long ago the latest non-voided fact for key landed, or
// (0, ErrNoFact). Implements the `since(key)==null → don't fire` guard from
// the spec — silence on no-data is "shuts up when uncertain".
func (s *Store) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
f, err := s.LatestFact(ctx, key)
if err != nil {
return 0, err
}
if now.Before(f.Ts) {
return 0, nil
}
return now.Sub(f.Ts), nil
}
// SetValue is a convenience for writing a structured (json) value at confidence 1.0
// from a tap. Self-taps (water, meal, shower) land here. Caller supplies source
// like "tap:water"; we serialize the value.
func (s *Store) SetValue(ctx context.Context, kind FactKind, key, source string, value any, ts time.Time) (int64, error) {
raw, err := json.Marshal(value)
if err != nil {
return 0, fmt.Errorf("marshal value: %w", err)
}
return s.WriteFact(ctx, ts, kind, key, string(raw), source, 1.0, sql.NullInt64{})
}
// CorrectValue voids the latest non-voided fact for key and writes a replacement
// in one transaction. Use this for "you corrected a bad fact" feedback — keeps
// the audit trail, supersedes the wrong value.
func (s *Store) CorrectValue(ctx context.Context, key, source string, value any, ts time.Time) (newID int64, err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
var oldID int64
err = tx.QueryRowContext(ctx, `
SELECT id FROM facts
WHERE key = ?
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC LIMIT 1`, key).Scan(&oldID)
if errors.Is(err, sql.ErrNoRows) {
// nothing to correct; write as a plain new fact instead — no voiding needed.
} else if err != nil {
return 0, fmt.Errorf("correct: find old: %w", err)
}
raw, merr := json.Marshal(value)
if merr != nil {
return 0, fmt.Errorf("marshal value: %w", merr)
}
var voids sql.NullInt64
if oldID != 0 {
voids = sql.NullInt64{Int64: oldID, Valid: true}
}
res, err := tx.ExecContext(ctx,
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
ts.UnixMilli(), string(KindSelf), key, string(raw), source, 1.0, voids)
if err != nil {
return 0, fmt.Errorf("write corrected: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, err
}
newID, err = res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("last insert id: %w", err)
}
return newID, nil
}
// VoidLatestFact voids the latest non-voided fact for key. It writes a new
// fact with voids_id pointing at the old one, keeping the audit trail intact.
// Returns the voided fact's ID and the new void-marker fact's ID.
// If no fact exists for the key, returns ErrNoFact.
func (s *Store) VoidLatestFact(ctx context.Context, key, source string, ts time.Time) (oldID, newID int64, err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
err = tx.QueryRowContext(ctx, `
SELECT id FROM facts
WHERE key = ?
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC LIMIT 1`, key).Scan(&oldID)
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, ErrNoFact
}
if err != nil {
return 0, 0, fmt.Errorf("void: find latest: %w", err)
}
res, err := tx.ExecContext(ctx,
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
ts.UnixMilli(), string(KindSelf), key, `"voided"`, source, 1.0,
sql.NullInt64{Int64: oldID, Valid: true})
if err != nil {
return 0, 0, fmt.Errorf("void: write void-marker: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, 0, err
}
newID, err = res.LastInsertId()
if err != nil {
return 0, 0, fmt.Errorf("void: last insert id: %w", err)
}
return oldID, newID, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanFact(r rowScanner) (Fact, error) {
var f Fact
var tsMilli int64
var kind string
var voids sql.NullInt64
if err := r.Scan(&f.ID, &tsMilli, &kind, &f.Key, &f.Value, &f.Source, &f.Confidence, &voids); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Fact{}, ErrNoFact
}
return Fact{}, err
}
f.Ts = time.UnixMilli(tsMilli).UTC()
f.Kind = FactKind(kind)
f.VoidsID = voids
return f, nil
}
// placeholders renders n comma-separated SQL bind markers.
func placeholders(n int) string {
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}