Files
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- 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>
2026-07-06 04:20:16 +04:00

123 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}