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)
}