// 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:)` 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:` 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) }