d52f60c54e
- Add CalendarEvents method to recordingAPI in auth_test.go - Add CalendarEvents method to fakeCore in handlers_test.go Co-Authored-By: opencode <opencode@anthropic.com>
123 lines
4.9 KiB
Go
123 lines
4.9 KiB
Go
// 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)
|
||
}
|