initial commit

This commit is contained in:
kami
2026-07-03 00:32:48 +02:00
commit 612583d59a
92 changed files with 14521 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
// loop/feedback.go — the cooldown auto-tuner: PURE math over resolved nudge
// outcomes.
//
// The feedback loop is data-flow-only at MVP — the daemon reads
// store.RecentOutcomes for a rule, calls TuneCooldown, writes the result back
// as a `facts (kind=config, source=feedback, key=cooldown:<rule>)` row. the
// Gatherer reads that row on the next tick and uses it as the active
// cooldown base (instead of the rule's static Base). misrouted tuning =
// constrained by the rule's Cooldown envelope (Min/Max), so a weird week
// can't mutate maven silent or stalker (per spec).
//
// All math here is PURE — no I/O. The daemon is the impure bit. Unit-testable
// with a fake Rule + outcomes slice.
package loop
import (
"encoding/json"
"time"
"github.com/kami/maven/internal/store"
)
// FeedbackSource — the provenance fixed string for auto-tuned cooldown facts.
// Matches the `Source` enumeration in store.Fact ("feedback"); named here so
// the gatherer + daemon reference the same string and a typo can't slip.
const FeedbackSource = "feedback"
// FeedbackKey — the config-fact key a rule's tuned cooldown lands under.
// `cooldown:<rule>` keeps it namespaced off any predicate-read key (`water`,
// `meal`, ...) so the tuner can't collide with a rule's own substrate. the
// gatherer reads it back via LatestFactBySource(source=feedback) — the same
// trust-by-provenance shape as ServiceDownRule's poll:healthcheck read (a
// module that doesn't own the `feedback` source can't poison a rule's
// cooldown once the auth source-scope lands).
func FeedbackKey(r Rule) string { return "cooldown:" + r.Name }
// Tuner cadence + window. Named constants (not config) because they pin the
// *shape* of the feedback loop, not its schedule; the cadence is daemon-config
// (config.AutotuneInterval), the window is the-loop's own.
const (
// TuneSampleN — how many recent resolved outcomes the tuner looks at.
// Small enough to react to a real change of pattern inside a day; large
// enough that one weird afternoon can't whipsaw the cooldown.
TuneSampleN = 8
// TuneMinOutcomes — below this many resolved outcomes there's NOT enough
// signal to tune; leave Base alone. cold boot + sparse rules don't get
// a dive on first sight.
TuneMinOutcomes = 4
)
// TuneCooldown — PURE. dead-simple ratio over the last N resolved outcomes:
//
// mostly ignored → lengthen (the nudge is noise at this cadence)
// mostly acted → shorten (the nudge is load-bearing; ring sooner)
// snoozed is neutral — the user reacted, just deferred; treating it as
// ignored would over-lengthen, as acted would over-shorten. mvp
// neutrality > picking the wrong direction.
//
// Math: newBase = base × (1 + α·(ignoredRate actedRate)).
// α=0.5 keeps the step tame: a fully-ignored week grows ~50%; fully-acted
// shrinks ~50%. Clamped to the rule's envelope (Min/Max) — the auto-tuner can
// never push the cooldown beyond the rule's designed bounds, so wrong math
// or a poisoned signal can't make maven silent (Min) or a stalker (Max).
//
// Empty outcomes ⇒ return Base unchanged (cold boot; sparse rule ⇒ no data,
// shut up when uncertain — same instinct as the gate's InertWhenNoData).
func TuneCooldown(r Rule, outcomes []string) time.Duration {
base := r.Cooldown.Base
if len(outcomes) == 0 {
return base
}
var acted, ignored int
for _, o := range outcomes {
switch o {
case store.NudgeActed:
acted++
case store.NudgeIgnored:
ignored++
// store.NudgeSnoozed: deliberately neutral (see comment).
}
}
n := float64(len(outcomes))
const alpha = 0.5
factor := 1 + alpha*(float64(ignored)/n-float64(acted)/n)
tuned := time.Duration(float64(base) * factor)
if tuned < r.Cooldown.Min {
tuned = r.Cooldown.Min
}
if tuned > r.Cooldown.Max {
tuned = r.Cooldown.Max
}
return tuned
}
// ParseCooldownFact — read a feedback fact (from FeedbackKey) back into the
// active base duration. Returns (dur, false) when the row is missing, zero,
// stale (source mismatch), or malformed — the gatherer falls back to the
// rule's static Base in that case (a bad feedback row must NOT crash the
// loop; same shut-up instinct as the gate's missing-key path).
func ParseCooldownFact(f store.Fact) (time.Duration, bool) {
if f.Ts.IsZero() || f.Source != FeedbackSource {
return 0, false
}
var ns int64
if err := json.Unmarshal([]byte(f.Value), &ns); err != nil {
return 0, false
}
if ns <= 0 {
return 0, false
}
return time.Duration(ns), true
}
// MarshalCooldown — inverse of ParseCooldownFact. Produces the JSON value the
// daemon hands to store.SetValue for a tuned cooldown (a nanosecond int64 —
// time.Duration's native JSON encoding). Callee owns the shape so a future
// richer payload (e.g. `{"base":..,"reason":"ignored"}`) is a one-place change.
func MarshalCooldown(d time.Duration) string {
b, _ := json.Marshal(int64(d)) // int64 marshal never errors
return string(b)
}
+152
View File
@@ -0,0 +1,152 @@
package loop
import (
"testing"
"time"
"github.com/kami/maven/internal/store"
)
// helper: rule with a known envelope so tested values stay stable even if
// the canonical WaterRule's numbers move.
func tuneRule(name string, base, min, max time.Duration) Rule {
return Rule{
Name: name,
Severity: Sev1,
Cooldown: Cooldown{Base: base, Min: min, Max: max},
}
}
func TestTuneCooldownEmptyReturnsBase(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
if got := TuneCooldown(r, nil); got != r.Cooldown.Base {
t.Fatalf("empty outcomes: want base %v, got %v", r.Cooldown.Base, got)
}
if got := TuneCooldown(r, []string{}); got != r.Cooldown.Base {
t.Fatalf("no outcomes: want base %v, got %v", r.Cooldown.Base, got)
}
}
func TestTuneCooldownThreeIgnoredOneActedLengthens(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
os := []string{store.NudgeIgnored, store.NudgeIgnored, store.NudgeIgnored, store.NudgeActed}
got := TuneCooldown(r, os)
// factor = 1 + 0.5*(.75 - .25) = 1.25 → 37.5m
want := time.Duration(float64(r.Cooldown.Base) * 1.25)
if got != want {
t.Fatalf("3 ignored / 1 acted: want %v, got %v", want, got)
}
}
func TestTuneCooldownMostlyIgnoredLengthens(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
os := repeat(store.NudgeIgnored, 7)
os = append(os, store.NudgeActed)
got := TuneCooldown(r, os)
// factor = 1 + 0.5*(7/8 - 1/8) = 1 + 0.5*0.75 = 1.375 → 41.25m
want := time.Duration(float64(r.Cooldown.Base) * 1.375)
if got != want {
t.Fatalf("mostly ignored: want %v, got %v", want, got)
}
}
func TestTuneCooldownMostlyActedShortens(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
os := repeat(store.NudgeActed, 7)
os = append(os, store.NudgeIgnored)
got := TuneCooldown(r, os)
// factor = 1 + 0.5*(1/8 - 7/8) = 1 - 0.5*0.75 = 0.625 → 18.75m
want := time.Duration(float64(r.Cooldown.Base) * 0.625)
if got != want {
t.Fatalf("mostly acted: want %v, got %v", want, got)
}
}
func TestTuneCooldownClampsAtMax(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 35*time.Minute)
os := repeat(store.NudgeIgnored, 8) // all ignored → factor 1.5 → 45m, clamped to Max 35m
if got := TuneCooldown(r, os); got != 35*time.Minute {
t.Fatalf("all ignored should clamp to Max: want 35m, got %v", got)
}
}
func TestTuneCooldownClampsAtMin(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 20*time.Minute, 6*time.Hour)
os := repeat(store.NudgeActed, 8) // all acted → factor 0.5 → 15m, clamped to Min 20m
if got := TuneCooldown(r, os); got != 20*time.Minute {
t.Fatalf("all acted should clamp to Min: want 20m, got %v", got)
}
}
func TestTuneCooldownSnoozedIsNeutral(t *testing.T) {
// all snoozed → both rates 0 → factor 1 → base unchanged.
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
os := repeat(store.NudgeSnoozed, 8)
if got := TuneCooldown(r, os); got != r.Cooldown.Base {
t.Fatalf("all snoozed should be neutral: want base %v, got %v", r.Cooldown.Base, got)
}
}
func TestFeedbackKeyMatchesRuleName(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
if got := FeedbackKey(r); got != "cooldown:water" {
t.Fatalf("FeedbackKey: want cooldown:water, got %q", got)
}
}
func TestParseCooldownFactRoundTrip(t *testing.T) {
d := 45 * time.Minute
f := store.Fact{
Key: "cooldown:water",
Source: FeedbackSource,
Value: MarshalCooldown(d),
Ts: refTime(),
}
got, ok := ParseCooldownFact(f)
if !ok {
t.Fatalf("ParseCooldownFact: want ok, got false (value %q)", f.Value)
}
if got != d {
t.Fatalf("round trip: want %v, got %v", d, got)
}
}
func TestParseCooldownFactRejectsBadRows(t *testing.T) {
now := refTime()
cases := []struct {
name string
f store.Fact
}{
{"missing value", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Ts: now}},
{"wrong source", store.Fact{Key: "cooldown:water", Source: "tap:water", Value: MarshalCooldown(30 * time.Minute), Ts: now}},
{"non-numeric", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Value: `"45m"`, Ts: now}},
{"negative ns", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Value: "-1000", Ts: now}},
{"zero ts", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Value: MarshalCooldown(30 * time.Minute)}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, ok := ParseCooldownFact(c.f); ok {
t.Fatalf("ParseCooldownFact %q: want not ok", c.name)
}
})
}
}
func TestCooldownForTakesBaseDuration(t *testing.T) {
last := refTime()
got := CooldownFor(45*time.Minute, last)
if got != last.Add(45*time.Minute) {
t.Fatalf("CooldownFor(base, last): want %v, got %v", last.Add(45*time.Minute), got)
}
if got := CooldownFor(45*time.Minute, time.Time{}); !got.IsZero() {
t.Fatalf("CooldownFor zero lastSend: want zero, got %v", got)
}
}
func repeat(v string, n int) []string {
out := make([]string, n)
for i := range out {
out[i] = v
}
return out
}
+150
View File
@@ -0,0 +1,150 @@
// loop/gather.go — the ONE impure piece in the loop.
//
// Gather builds a State snapshot under the store lock at the start of each
// tick. From there, every predicate and the gate are pure functions over State.
//
// Why centralize the I/O: the loop is "dumb + deterministic", the spec
// repeatedly enforces a no-I/O contract on predicates. Centralizing read here
// makes the contract checkable (anywhere outside gather.go doing I/O is a bug).
package loop
import (
"context"
"time"
"github.com/kami/maven/internal/store"
)
// Gatherer — holds nothing mutable; the Store is the only dependency. The
// daemon runs one Gatherer per tick.
type Gatherer struct {
store *store.Store
rules []Rule
}
func NewGatherer(s *store.Store, rules []Rule) *Gatherer {
return &Gatherer{store: s, rules: rules}
}
// GatherState — reads the store ONCE and assembles the snapshot the pure Tick
// will operate on.
//
// Reads the loop needs:
// - presence: probes (ts per signal key), current bucket, compute score, resolve.
// - facts: every key any rule's Predicate OR InertWhenNoData names.
// - last nudge per rule (for cooldown).
// - due reminders (the loop reuses the loop for reminders; we gather them here).
// - env flags QuietHours / CalendarBusy — read as facts (kind=config/env).
//
// All reads share a single read-only transaction for a consistent snapshot.
func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []store.Reminder, error) {
// presence first — pure function over probes. the gate checks the bucket;
// delivery (later) checks the score.
probes, err := g.store.PresenceProbes(ctx)
if err != nil {
return State{}, nil, err
}
lastBucket, _, _, err := g.store.LoadPresenceState(ctx)
if err != nil {
return State{}, nil, err
}
score := store.PresenceScore(now, probes)
bucket := store.Resolve(score, lastBucket)
// collect every key any rule references (predicate + inert list).
// Daemon rules are small (≤ ~30, per the "revisit at 30 rules" line);
// a single map over rules per tick is negligible at 60s cadence.
wanted := make(map[string]struct{})
for _, r := range g.rules {
// We don't introspect the predicate closure (Go can't); the rule author
// declares InertWhenNoData for keys the predicate reads. Reuse that list.
for _, k := range r.InertWhenNoData {
wanted[k] = struct{}{}
}
}
// presence signal keys live in facts too — included via the probes path,
// but also surface via Fact() for rules that want direct access (e.g. break).
for _, sig := range store.PresenceSignals {
wanted[sig.Key] = struct{}{}
}
facts := make(map[string]store.Fact, len(wanted))
for k := range wanted {
f, err := g.store.LatestFact(ctx, k)
if err == nil {
facts[k] = f
continue
}
if err == store.ErrNoFact {
continue // missing ⇒ shut up; the gate handles it
}
return State{}, nil, err
}
// last nudge per rule + cooldown-until derived from the active cooldown.
// "active" = the feedback tuner's persisted base if one exists, else the
// rule's static Base. LatestFactBySource is the trust-by-provenance read
// (a module that doesn't own the `feedback` source can't poison a rule's
// cooldown once the auth source-scope lands — same shape as ServiceDownRule).
lastNudge := make(map[string]store.Nudge, len(g.rules))
cooldownUntil := make(map[string]time.Time, len(g.rules))
for _, r := range g.rules {
base := r.Cooldown.Base
if f, err := g.store.LatestFactBySource(ctx, FeedbackKey(r), FeedbackSource); err == nil {
if tuned, ok := ParseCooldownFact(f); ok {
base = tuned
}
} else if err != store.ErrNoFact {
return State{}, nil, err
}
n, err := g.store.LastNudge(ctx, r.Name)
if err == nil {
lastNudge[r.Name] = n
cooldownUntil[r.Name] = CooldownFor(base, n.Ts)
continue
}
if err == store.ErrNudgeNotFound {
continue // never fired → no cooldown
}
return State{}, nil, err
}
// env flags — QuietHours / CalendarBusy as config facts.
// QuietHours: presence != reachability, sleep/quiet-hours handled separately
// in the gate. We read a config `quiet_hours` fact for the boolean.
var quiet bool
if f, ok := readFact(ctx, g.store, "quiet_hours"); ok {
quiet = f.Value == "true" || f.Value == `"true"`
}
var calBusy bool
if f, ok := readFact(ctx, g.store, "calendar_busy"); ok {
calBusy = f.Value == "true" || f.Value == `"true"`
}
// due reminders — gate-bypassing class. read here, the daemon emits them.
due, err := g.store.DueReminders(ctx, now)
if err != nil {
return State{}, nil, err
}
s := State{
Now: now,
Presence: bucket,
PresenceScore: score,
Facts: facts,
LastNudge: lastNudge,
SnoozeUntil: nil, // no snooze persistence yet — daemon wires in
CooldownUntil: cooldownUntil,
QuietHours: quiet,
CalendarBusy: calBusy,
}
return s, due, nil
}
func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool) {
f, err := s.LatestFact(ctx, key)
if err != nil {
return store.Fact{}, false
}
return f, true
}
+137
View File
@@ -0,0 +1,137 @@
package loop
import (
"time"
"github.com/kami/maven/internal/store"
)
// Gate — universal, applied by the loop, never per-rule.
//
// quiet-hours, presence, cooldown, snooze, calendar-busy all live in ONE
// fires(). Cross-cutting restraint in one place or it drifts.
//
// The gate does NOT itself decide "should this rule run" — the Rule.Predicate
// does. The gate answers "is it ALLOWED to fire right NOW" given the snapshot.
// Suppression-context ("don't nag mid-meeting") moves INTO the gate as an env
// predicate, not the LLM's job — same boundary as "rules decide, llm phrases."
//
// Gate is pure. no I/O. reads State + Rule only.
func Gate(s State, r Rule) bool {
now := s.Now
// snooze — per-rule "leave me alone until X". overrides everything below.
if until, ok := s.SnoozeUntil[r.Name]; ok && now.Before(until) {
return false
}
// cooldown — most recent same-rule nudge + base/feedback-tuned duration.
// Persisted as `facts (source=feedback)`; Gatherer rolls it into CooldownUntil.
if until, ok := s.CooldownUntil[r.Name]; ok && now.Before(until) {
return false
}
// quiet hours — care nudges (sev12) shut up. ops (sev ≥3) still surface
// (a failed backup at 2am genuinely matters and maven routes to telegram).
if s.QuietHours && r.Severity.IsCare() {
return false
}
// calendar busy — "don't nag mid-meeting" lifted INTO the gate as an env
// predicate; not the LLM's call.
if s.CalendarBusy && r.Severity.IsCare() {
return false
}
// presence — sev12 DROP on away (missed water nudge is noise).
// sev ≥3 HOLDS — see the delivery channel-routing table in the spec.
// (the loop doesn't pick the channel; it just decides whether to emit.)
if s.Presence == store.Away && r.Severity.IsCare() {
return false
}
// no-data inertness — since(key)==null → don't fire. shut up when uncertain.
// The predicate MAY have encoded this itself; the gate enforces it for any
// rule that declared InertWhenNoData keys.
for _, k := range r.InertWhenNoData {
if _, ok := s.Fact(k); !ok {
return false
}
}
return true
}
// Candidate — a rule that Wants (predicate true) AND Is Allowed (gate true).
// The loop picks one per tick (max severity).
type Candidate struct {
Rule Rule
Severity Severity
State State // snapshot at evaluation time — for the phraser's context
}
// Tick — PURE. Evaluates the configured rules against the snapshot, returns
// AT MOST one proactive candidate (max severity, with a deterministic tie-break).
// Returns nil when nothing fires ("shuts up" is the default outcome of a tick).
//
// Phrasing + sending happen OUT of the loop — the daemon hands Candidate to
// the phraser (LFM) and delivery module. The loop just decides.
//
// Reminders are NOT handled here — they're a separate, gate-bypassing class.
// See DueReminders (gathered separately) and RemindDecisions (the loop output
// flag for the daemon).
func Tick(s State, rules []Rule) *Candidate {
var fire *Candidate
for _, r := range rules {
if !r.Predicate(s) {
continue // rule doesn't want to fire — skip gate entirely (cheap path)
}
if !Gate(s, r) {
continue // wanted but suppressed this tick
}
c := Candidate{Rule: r, Severity: r.Severity, State: s}
if fire == nil {
fire = &c
continue
}
// max severity wins; tie-break: severity desc, then name asc for determinism.
if c.Severity > fire.Severity ||
(c.Severity == fire.Severity && c.Rule.Name < fire.Rule.Name) {
fire = &c
}
}
return fire
}
// ReminderDecision — a due reminder the daemon should deliver now.
// NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours;
// that's the point). Snooze still applies — represented by a separate
// snooze-until the gatherer consults; for the scaffold, fired-reminders move
// straight to MarkReminder(fired).
type ReminderDecision struct {
Reminder store.Reminder
State State
}
// RemindDecisions — returns all due reminders (without gating their delivery
// by restraint). Pure: accepts an already-filtered (due) list. The Gatherer
// produces that list from `fire_ts <= now AND pending`.
func RemindDecisions(s State, due []store.Reminder) []ReminderDecision {
out := make([]ReminderDecision, 0, len(due))
for _, r := range due {
out = append(out, ReminderDecision{Reminder: r, State: s})
}
return out
}
// CooldownFor — helper for the Gatherer: given the active cooldown base
// (the rule's static Base, OR the feedback tuner's persisted tuning) and the
// last send ts, compute the wall-clock "cooldown-until" the gate will check.
// Pure. The auto-tuner writes the base the gatherer reads as a feedback
// fact; this function just adds it to the last send.
func CooldownFor(base time.Duration, lastSend time.Time) time.Time {
if lastSend.IsZero() {
return time.Time{} // never sent → no cooldown active
}
return lastSend.Add(base)
}
+382
View File
@@ -0,0 +1,382 @@
package loop
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/store"
)
func refTime() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) }
func factAt(key, source, value string, ts time.Time) store.Fact {
return store.Fact{Ts: ts, Key: key, Source: source, Value: value, Confidence: 1.0}
}
// ----------------------------- Tick + Gate -----------------------------------
func TestTickNothingFiresColdBoot(t *testing.T) {
// cold boot — no facts at all. every rule's InertWhenNoData kicks in; gate
// returns false; Tick returns nil. "shuts up when uncertain" is the default.
s := State{Now: refTime(), Presence: store.Away}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("cold boot: want nil, got %+v", got)
}
}
func TestTickWaterFiresWhenThirstyAndPresent(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
}
got := Tick(s, DefaultRules())
if got == nil || got.Rule.Name != "water" {
t.Fatalf("want water candidate, got %+v", got)
}
if got.Severity != Sev1 {
t.Fatalf("water sev mismatch: %d", got.Severity)
}
}
func TestTickWaterSuppressedOnAwayCareDrops(t *testing.T) {
// sev1 (care) → drops on away. spec: "a missed water nudge is noise."
now := refTime()
s := State{
Now: now,
Presence: store.Away,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("care on away: want nil, got %+v", got)
}
}
func TestTickWaterSuppressedInQuietHours(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
QuietHours: true,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("care in quiet hours: want nil, got %+v", got)
}
}
func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) {
// sev4 ops hard — must survive both away AND quiet hours. the gate only
// suppresses sev≤2 for either flag; sev4 is the disk-fire alarm.
now := refTime()
s := State{
Now: now,
Presence: store.Away,
QuietHours: true,
Facts: map[string]store.Fact{
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
},
}
got := Tick(s, DefaultRules())
if got == nil || got.Rule.Name != "service_down" || got.Severity != Sev4 {
t.Fatalf("ops hard survives: want service_down/sev4, got %+v", got)
}
}
func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) {
// a non-poll:uptimekuma source recording "down" must NOT fire the ops
// rule — compromised poller / ambient can't forge a trigger.
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"service_down": factAt("service_down", "ambient", `"down"`, now.Add(-1*time.Minute)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("forged source: want nil, got %+v", got)
}
}
func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) {
// both water (sev1) and service_down (sev4) want to fire and clear the gate.
// max severity wins — disk-fire preempts water. never dogpile.
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
},
}
got := Tick(s, DefaultRules())
if got == nil || got.Rule.Name != "service_down" {
t.Fatalf("max sev wins: want service_down, got %+v", got)
}
}
func TestTickCooldownSuppresses(t *testing.T) {
now := refTime()
// 4h since water (would fire) — but cooldown until now+10min. Suppressed.
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
CooldownUntil: map[string]time.Time{
"water": now.Add(10 * time.Minute),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("cooldown: want nil, got %+v", got)
}
}
func TestTickSnoozeSuppressesBeforeCooldown(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
SnoozeUntil: map[string]time.Time{
"water": now.Add(time.Hour),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("snooze: want nil, got %+v", got)
}
}
func TestTickCalendarBusySuppressesCare(t *testing.T) {
// "don't nag mid-meeting" lives in the gate as an env predicate.
now := refTime()
s := State{
Now: now,
Presence: store.Present,
CalendarBusy: true,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("busy care: want nil, got %+v", got)
}
}
func TestTickPredicateFalseSkipsGateEntirely(t *testing.T) {
// water since lastFact < 3h ⇒ predicate false ⇒ not a candidate at all,
// regardless of any gate state. cheap path; gate never consulted.
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-30*time.Minute)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
t.Fatalf("predicate false: want nil, got %+v", got)
}
}
func TestGateNoDataInertShutsUp(t *testing.T) {
// predicate true (carelessly), but rule declared InertWhenNoData and key
// is missing in the snapshot — gate must still return false.
s := State{Now: refTime(), Presence: store.Present}
iwantfire := Rule{
Name: "x",
Severity: Sev1,
Predicate: func(State) bool { return true },
InertWhenNoData: []string{"missing_key"},
}
if Gate(s, iwantfire) {
t.Fatalf("no-data rule should be inert, got fire")
}
}
func TestRemindDecisionsDoesNotGate(t *testing.T) {
// reminders bypass restraint — they pass through untouched even in quiet,
// away, etc. this is the documented two-delivery-path split.
now := refTime()
s := State{Now: now, Presence: store.Away, QuietHours: true, CalendarBusy: true}
due := []store.Reminder{{ID: 1, Payload: `{"text":"wake me"}`}}
got := RemindDecisions(s, due)
if len(got) != 1 || got[0].Reminder.ID != 1 {
t.Fatalf("reminders must bypass gate, got %+v", got)
}
}
// ----------------------------- Gatherer + real store -------------------------
func TestGathererEndToEndWaterFires(t *testing.T) {
path := t.TempDir() + "/m.db"
st, err := store.Open(context.Background(), path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
// write a water fact 4h ago — drop into the past by direct INSERT.
now := refTime()
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", "250ml", now.Add(-4*time.Hour)); err != nil {
t.Fatal(err)
}
// (no `break` fact seeded — BreakRule's InertWhenNoData keeps it inert,
// so water is the only care candidate.)
// silence env flags so they don't accidentally suppress.
if _, err := st.SetValue(ctx, store.KindConfig, "quiet_hours", "promote", "false", now); err != nil {
t.Fatal(err)
}
if _, err := st.SetValue(ctx, store.KindConfig, "calendar_busy", "promote", "false", now); err != nil {
t.Fatal(err)
}
g := NewGatherer(st, DefaultRules())
snap, due, err := g.GatherState(ctx, now)
if err != nil {
t.Fatalf("GatherState: %v", err)
}
if len(due) != 0 {
t.Fatalf("no reminders due, got %d", len(due))
}
// cold-boot presence (no signal facts) ⇒ away ⇒ care should drop.
if snap.Presence != store.Away {
t.Fatalf("cold presence: want Away, got %s (score %f)", snap.Presence, snap.PresenceScore)
}
// water predicate true but presence away ⇒ gate suppresses care ⇒ nil.
if got := Tick(snap, DefaultRules()); got != nil {
t.Fatalf("away cold presence should suppress care: want nil, got %+v", got)
}
// now create a fresh desk_active signal so presence flips to present.
if _, err := st.SetValue(ctx, store.KindSelf, "desk_active", "infer:hyprland", "1", now.Add(-1*time.Second)); err != nil {
t.Fatal(err)
}
snap2, _, err := g.GatherState(ctx, now)
if err != nil {
t.Fatal(err)
}
if snap2.Presence != store.Present {
t.Fatalf("with desk signal: want Present, got %s (score %f)", snap2.Presence, snap2.PresenceScore)
}
got := Tick(snap2, DefaultRules())
if got == nil || got.Rule.Name != "water" {
t.Fatalf("present + thirsty should fire water, got %+v", got)
}
}
// TestGathererUsesFeedbackTunedCooldown — write a `cooldown:water` feedback
// fact, then verify the gatherer's CooldownUntil for the rule uses the tuned
// base (last nudge ts + tuned base), not the rule's static Base. This is the
// end-to-end shape of the feedback loop: tuner writes fact → gatherer reads
// it next tick → gate consults the adjusted CooldownUntil.
func TestGathererUsesFeedbackTunedCooldown(t *testing.T) {
path := t.TempDir() + "/m.db"
st, err := store.Open(context.Background(), path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
now := refTime()
// seed water + a presence signal so the rule has a snapshot worth gating
// against (we're not asserting Tick here — just the CooldownUntil field).
if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)); err != nil {
t.Fatal(err)
}
if _, err := st.SetValue(ctx, store.KindSelf, "desk_active", "infer:hyprland", "1", now.Add(-1*time.Second)); err != nil {
t.Fatal(err)
}
if _, err := st.SetValue(ctx, store.KindConfig, "quiet_hours", "promote", "false", now); err != nil {
t.Fatal(err)
}
if _, err := st.SetValue(ctx, store.KindConfig, "calendar_busy", "promote", "false", now); err != nil {
t.Fatal(err)
}
// send a water nudge 10m ago so cooldown-until is lastSend+base.
sendTs := now.Add(-10 * time.Minute)
nudgeID, err := st.RecordNudge(ctx, "water", "voice", "drink water", sendTs)
if err != nil {
t.Fatal(err)
}
// leave it pending so LastNudge still surfaces it as the most recent send.
g := NewGatherer(st, DefaultRules())
// baseline: rule's static Base (30m) → CooldownUntil == sendTs + 30m.
snap1, _, err := g.GatherState(ctx, now)
if err != nil {
t.Fatalf("gather baseline: %v", err)
}
wantStatic := sendTs.Add(30 * time.Minute)
if got := snap1.CooldownUntil["water"]; got != wantStatic {
t.Fatalf("baseline cooldown-until: want %v, got %v", wantStatic, got)
}
// the feedback tuner writes a tuned base as facts(kind=config,
// source=feedback, key=cooldown:water). simulate the daemon: write a
// 5h tuned cooldown fact.
tuned := 5 * time.Hour
water := WaterRule()
if _, err := st.SetValue(ctx, store.KindConfig, FeedbackKey(water), FeedbackSource, tuned, now); err != nil {
t.Fatal(err)
}
snap2, _, err := g.GatherState(ctx, now)
if err != nil {
t.Fatalf("gather tuned: %v", err)
}
wantTuned := sendTs.Add(tuned)
if got := snap2.CooldownUntil["water"]; got != wantTuned {
t.Fatalf("tuned cooldown-until: want %v, got %v", wantTuned, got)
}
// a poisoned row from a non-feedback source must be ignored (trust by
// provenance — gatherer reads LatestFactBySource(source=feedback), so a
// tap:water row at the same key doesn't reach the cooldown).
if _, err := st.SetValue(ctx, store.KindConfig, FeedbackKey(water), "tap:water", 1*time.Minute, now); err != nil {
t.Fatal(err)
}
snap3, _, err := g.GatherState(ctx, now)
if err != nil {
t.Fatalf("gather poisoned: %v", err)
}
// LatestFactBySource returns the latest NON-voided feedback row, which is
// still the 5h one we wrote — the tap:water row is invisible to this read.
if got := snap3.CooldownUntil["water"]; got != wantTuned {
t.Fatalf("poisoned row leaked: want %v, got %v", wantTuned, got)
}
// sanity: the water nudge row is still findable (the gatherer's LastNudge
// + gate's cooldown consult it).
n, err := st.LastNudge(ctx, "water")
if err != nil || n.ID != nudgeID {
t.Fatalf("LastNudge: want id %d, got %+v err=%v", nudgeID, n, err)
}
// sanity: the feedback fact lookup round-trips via ParseCooldownFact too.
fb, err := st.LatestFactBySource(ctx, FeedbackKey(water), FeedbackSource)
if err != nil {
t.Fatalf("LatestFactBySource feedback: %v", err)
}
if d, ok := ParseCooldownFact(fb); !ok || d != tuned {
t.Fatalf("ParseCooldownFact round-trip: want %v ok, got %v ok=%v", tuned, d, ok)
}
}
+151
View File
@@ -0,0 +1,151 @@
package loop
import "time"
// Rule — a proactive rule. Rules are CODE, not a DSL config — until ~30 rules
// and you feel the pain (per spec). A Rule has a name (ids it in nudges.outcome
// for the feedback loop), a Severity, a pure Predicate, and a cooldown.
//
// The Predicate answers "should this rule want to fire given the State?" —
// only the check against the snapshot. It is pure, no I/O. The GATE answers
// "are we allowed to fire it right now?" (quiet hours, cooldown, etc) —
// applied by the loop, never per-rule.
//
// Cooldown is the BASE duration between same-rule nudges. The feedback
// auto-tuner scales it over time (mostly ignored → lengthen; acted → leave).
// Bounds belong at the daemon-config level; here we just carry the base.
type Rule struct {
Name string
Severity Severity
Cooldown // base cooldown + bounded-duration envelope for the auto-tuner
Predicate func(State) bool
// InertWhenNoData — most rules should be silent when their substrate key is
// missing (since(key)==null → don't fire). If the predicate already encodes
// that check itself, leave this empty. Otherwise set to the key(s) the rule
// needs and the gate will skip the rule when any are missing.
InertWhenNoData []string
}
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
// mutate maven silent or stalker. Base is what the rule ships with; Min/Max
// bound the feedback-driven adjustments persisted as `facts (source=feedback)`.
type Cooldown struct {
Base time.Duration
Min time.Duration
Max time.Duration
}
// Canonical care/ops rules — NOT a config DSL. Code, so the predicate is
// inspectable and unit-tested. Daemon wires these up; the loop just iterates.
// WaterRule — sev1 care: if it's been ≥3h since a `water` fact, fire.
// Inert when no water fact exists at all (shuts up when uncertain).
func WaterRule() Rule {
return Rule{
Name: "water",
Severity: Sev1,
Cooldown: Cooldown{Base: 30 * time.Minute, Min: 15 * time.Minute, Max: 6 * time.Hour},
InertWhenNoData: []string{"water"},
Predicate: func(s State) bool {
d, ok := s.Since("water")
if !ok {
return false // no data → shut up
}
return d >= 3*time.Hour
},
}
}
// MealRule — sev1 care: if ≥6h since an `meal` fact, fire. Inert without data.
func MealRule() Rule {
return Rule{
Name: "meal",
Severity: Sev1,
Cooldown: Cooldown{Base: 60 * time.Minute, Min: 30 * time.Minute, Max: 8 * time.Hour},
InertWhenNoData: []string{"meal"},
Predicate: func(s State) bool {
d, ok := s.Since("meal")
if !ok {
return false
}
return d >= 6 * time.Hour
},
}
}
// BreakRule — sev2 care: ≥90min of continuous desk activity without a break.
// Reads both `desk_active` (fresh input ⇒ at desk) and `break` (last taken).
// Inert unless both exist — can't claim continuous activity without both anchors.
func BreakRule() Rule {
return Rule{
Name: "break",
Severity: Sev2,
Cooldown: Cooldown{Base: 45 * time.Minute, Min: 20 * time.Minute, Max: 4 * time.Hour},
InertWhenNoData: []string{"desk_active", "break"},
Predicate: func(s State) bool {
dDesk, ok1 := s.Since("desk_active")
dBreak, ok2 := s.Since("break")
if !ok1 || !ok2 {
return false // shut up until we have both anchors
}
// at desk (fresh input within 2min) AND no break for ≥90min.
return dDesk <= 2*time.Minute && dBreak >= 90*time.Minute
},
}
}
// ServiceDownRule — sev4 ops hard: the `service_down` aggregate fact reads
// "down". Source must be poll:uptimekuma — kuma is the source of truth for
// service up/down (mavpoll writes this key). The predicate is provenance-scoped:
// a compromised poller writing under a different source can't forge the trigger.
func ServiceDownRule() Rule {
return Rule{
Name: "service_down",
Severity: Sev4,
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
InertWhenNoData: []string{"service_down"},
Predicate: func(s State) bool {
f, ok := s.Fact("service_down")
if !ok || f.Ts.IsZero() {
return false
}
// value is json `"down"`; trivial check keyed off source provenance.
return f.Source == "poll:uptimekuma" && f.Value == `"down"`
},
}
}
// NetdataCriticalRule — sev3 ops soft: netdata has a CRITICAL alarm active
// (disk/mem/cert/temp). The `netdata_alarm` aggregate fact (mavpoll, source
// poll:netdata) reads "critical". Sev3 not sev4: netdata resource alarms are
// "look soon", not "wake me" — a full disk matters, but kuma's service_down is
// the hard page. Provenance-scoped to poll:netdata.
func NetdataCriticalRule() Rule {
return Rule{
Name: "netdata_critical",
Severity: Sev3,
Cooldown: Cooldown{Base: 20 * time.Minute, Min: 10 * time.Minute, Max: 2 * time.Hour},
InertWhenNoData: []string{"netdata_alarm"},
Predicate: func(s State) bool {
f, ok := s.Fact("netdata_alarm")
if !ok || f.Ts.IsZero() {
return false
}
return f.Source == "poll:netdata" && f.Value == `"critical"`
},
}
}
// DefaultRules — the canonical set the daemon wires. Add more as code, not config.
// Order here is NOT load-bearing — the loop picks max severity, ties broken by
// (severity desc, name asc) for deterministic output.
func DefaultRules() []Rule {
return []Rule{
WaterRule(),
MealRule(),
BreakRule(),
ServiceDownRule(),
NetdataCriticalRule(),
}
}
+109
View File
@@ -0,0 +1,109 @@
// Package loop is maven's proactive trigger engine.
//
// Loop contract (from spec):
//
// - ticks ~60s. no llm. 99% of ticks evaluate a few predicates and die for free.
// - a predicate is `(State) -> Bool`, PURE, no i/o → unit-testable with a fake State.
// - since(key)==null → don't fire. silence on no-data = "shuts up when uncertain".
// - the GATE is universal, applied by the loop, never per-rule. quiet-hours,
// presence, cooldown, snooze, calendar-busy all live in ONE fires(). cross-
// cutting restraint in one place or it drifts.
// - ONE nudge per tick (max severity), never dogpile.
// - rules as code, not a DSL. revisit at ~30 rules.
//
// Reminders are a SEPARATE class — reuses the loop, NOT a second scheduler:
// - predicate: `fire_ts <= now AND pending`
// - BYPASSES the restraint gate — "wake me 7" fires in quiet hours; that's the point
// - snooze still applies; fires once (pending → fired)
//
// Architecture: the Gatherer is the only impure bit — it builds a State snapshot
// under the store lock. Everything from there on is PURE functions over State.
// Phrasing (the llm lane) and delivery are out of scope here — the loop emits
// Decisions; the daemon wires them to phrasing + delivery.
package loop
import (
"time"
"github.com/kami/maven/internal/store"
)
// Severity — higher = more insistent (harder to suppress).
//
// Per the spec's delivery table:
//
// sev12: care nudges (water, meal, break). voice when present; DROP on away.
// sev3: ops soft (backup failed, cert soon). voice + once over ntfy when away.
// sev4: ops hard (disk critical, service down). voice + ntfy present;
// telegram, repeat til ack, when away.
//
// "Max severity" in one-nudge-per-tick therefore means: the loudest/most insistent
// candidate wins — a disk-full nudge (sev4) preempts a water nudge (sev1).
type Severity int
const (
Sev1 Severity = 1 // care, lowest insistence — drops on away
Sev2 Severity = 2
Sev3 Severity = 3 // ops soft
Sev4 Severity = 4 // ops hard, highest insistence
)
func (s Severity) IsCare() bool { return s <= Sev2 } // suppressed by quiet hours + away
// State — the loop's view of the world, gathered under the store lock at the
// start of each tick. From here on everything is pure — predicates and the gate
// read ONLY this struct and never touch the store.
//
// Keys in Facts/LastNudge/SnoozeUntil/CooldownUntil are rule-specific lookups
// the rules + gate have pre-arranged. Presence/PresenceScore/Now are global.
// The Gatherer decides what to populate; rules see what's in the snapshot.
type State struct {
Now time.Time
Presence store.Bucket
PresenceScore float64
// Latest non-voided fact per key the loop requested at gather time.
// Missing key (or zero Value Fact with Ts.IsZero) ⇒ since(key)==null ⇒ don't fire.
Facts map[string]store.Fact
// Last nudge per rule, for cooldown enforcement (newest send).
LastNudge map[string]store.Nudge
// Per-rule snooze-until — explicit "don't bother me about this rule until X".
// Overlays on top of cooldown; both must be clear to fire.
SnoozeUntil map[string]time.Time
// Per-rule cooldown-until — derived from LastNudge + cooldown duration
// (the auto-tuner adjusts the duration via feedback outcomes).
CooldownUntil map[string]time.Time
// Cross-cutting env flags derived from facts at gather time:
// QuietHours — the care gate suppresses sev12 when true. ops still surface.
QuietHours bool
// CalendarBusy — "don't nag mid-meeting". acts as an env predicate in the gate.
CalendarBusy bool
}
// Fact returns the latest non-voided fact for key, or
// (store.Fact{}, false) — the predicate's "no data" case.
// since(key)==null → don't fire is implemented by checking the bool.
func (s State) Fact(key string) (store.Fact, bool) {
f, ok := s.Facts[key]
if !ok || f.Ts.IsZero() {
return store.Fact{}, false
}
return f, true
}
// Since returns the duration since the latest fact for key, or (0,false).
// "false" ⇒ no data ⇒ shuts up when uncertain.
func (s State) Since(key string) (time.Duration, bool) {
f, ok := s.Fact(key)
if !ok {
return 0, false
}
if s.Now.Before(f.Ts) {
return 0, true
}
return s.Now.Sub(f.Ts), true
}