Announce tick-inferred routines, opt-in and rate-limited (#247, #43)

The digestion tick already runs the pattern detector over all recorded
events (67563ed) and writes a proposed_routines row. What was missing is
the other half of #247: a proposal that nobody is at the mic for reaches
nothing but the /routines page, so a pattern noticed at 03:00 is only
seen if he goes looking.

This wires the tick's proposals into the existing care-delivery path
rather than a second channel: sev1 nudge, loop.Gate, dispatcher, same
routing table as an accepted routine. Restraints, since a feature that
speaks unprompted is the easiest way to turn Maven into a nag:

  - off unless configured — the new pattern_proposals block, absent by
    default, and deploy/mavend.json ships notify: false;
  - at most one announcement per tick however many patterns surfaced;
  - at most one per cooldown (24h default) across all pairs;
  - sev1, so quiet hours, away and snooze suppress it;
  - suppressed means dropped, not queued — /routines still has it;
  - once per pair for good, since proposed_routines is
    UNIQUE(action, object) and the row survives dismissal.

The body is pattern.PhraseRoutine's literal Russian, not LLM-worded, so
an inferred routine cannot arrive describing something never observed.

Also raises pattern.MinEvents from 3 to 4 — the interval-quality item on
#43. Two intervals with a ±50% band is a coincidence with a mean, not a
pattern, and now that a scan of all history can announce itself the cost
of a false positive is a permanent dismissal of that pair.
This commit is contained in:
kami
2026-08-01 01:37:50 +04:00
parent c5317eb2b4
commit 766ca091a7
8 changed files with 350 additions and 39 deletions
+2 -2
View File
@@ -261,7 +261,7 @@ func run(args []string) error {
tickInterval := time.Duration(cfg.TickInterval)
repeatInterval := time.Duration(cfg.RepeatInterval)
autotuneInterval := time.Duration(cfg.AutotuneInterval)
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
coreAPI = &daemonAPI{
@@ -438,7 +438,7 @@ func run(args []string) error {
tickInterval := time.Duration(cfg.TickInterval)
repeatInterval := time.Duration(cfg.RepeatInterval)
autotuneInterval := time.Duration(cfg.AutotuneInterval)
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
// Swap the CoreAPI from the locked placeholder to the real store adapter.
+174 -11
View File
@@ -3,9 +3,14 @@ package main
import (
"context"
"database/sql"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/store"
)
@@ -27,16 +32,16 @@ func seedRefillEvents(t *testing.T, st *store.Store, ctx context.Context, base t
// TestTickDetectsPatternFromStoredEvents proves the tick notices a pattern on
// its own, reading straight from the store — not as a side effect of a live
// utterance (Vikunja #43). Three weekly events with no voice turn in sight
// must produce exactly one proposed routine.
// utterance (Vikunja #43). MinEvents weekly events with no voice turn in
// sight must produce exactly one proposed routine.
func TestTickDetectsPatternFromStoredEvents(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, 3)
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
tl.detectPatterns(ctx, now)
tl.detectPatterns(ctx, now, loop.State{})
rows, err := st.ListProposedRoutines(ctx)
if err != nil {
@@ -58,11 +63,11 @@ func TestTickPatternDetectionIsIdempotent(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, 3)
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
tl.detectPatterns(ctx, now)
tl.detectPatterns(ctx, now.Add(time.Hour))
tl.detectPatterns(ctx, now, loop.State{})
tl.detectPatterns(ctx, now.Add(time.Hour), loop.State{})
rows, err := st.ListProposedRoutines(ctx)
if err != nil {
@@ -81,10 +86,10 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, 3)
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
tl.detectPatterns(ctx, now)
tl.detectPatterns(ctx, now, loop.State{})
rows, err := st.ListProposedRoutines(ctx)
if err != nil {
@@ -99,8 +104,8 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
// More events for the same pair arrive, and the tick runs again — a
// dismissed pattern must not resurface.
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), 3)
tl.detectPatterns(ctx, now.Add(60*24*time.Hour))
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), pattern.MinEvents)
tl.detectPatterns(ctx, now.Add(60*24*time.Hour), loop.State{})
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
if err != nil {
@@ -120,3 +125,161 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
t.Errorf("status = %s, want dismissed", all[0].Status)
}
}
// proposalRule — the rule name announceProposal uses for the seeded pair.
const proposalRule = "proposal:refill cat_water"
// TestTickProposalSilentByDefault — detection is always on, announcing is not.
// With no pattern_proposals block the tick still records the proposal, and says
// nothing about it: Maven is not autonomous, so a behaviour that speaks without
// being asked stays off until it is configured.
func TestTickProposalSilentByDefault(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
markPresent(t, st, ctx, now)
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.tick(ctx, now)
if n := countSends(sink, proposalRule); n != 0 {
t.Fatalf("announced %d proposals with no config, want 0", n)
}
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
if err != nil {
t.Fatalf("list proposed: %v", err)
}
if len(rows) != 1 {
t.Fatalf("proposed routines = %d, want 1 (silent, but recorded)", len(rows))
}
}
// TestTickAnnouncesProposalWhenConfigured — with notify on, the proposal goes
// out once through the ordinary delivery path, worded by the detector itself.
// Later ticks stay quiet because the pair is already proposed: one pattern is
// one announcement, ever.
func TestTickAnnouncesProposalWhenConfigured(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
markPresent(t, st, ctx, now)
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
tl.tick(ctx, now)
var got *delivery.Sendable
for i := range sink.sends {
if sink.sends[i].RuleName == proposalRule {
got = &sink.sends[i]
}
}
if got == nil {
t.Fatalf("proposal was not announced; sends=%+v", sink.sends)
}
if !strings.Contains(got.Body, "напоминать?") {
t.Errorf("body = %q, want the detector's own question", got.Body)
}
if got.Channel != delivery.ChannelVoice {
t.Errorf("channel = %v, want voice (sev1, present)", got.Channel)
}
// A month of further ticks: the pair already has a row, so there is
// nothing new to detect and nothing more to say.
sink.sends = nil
later := now.Add(40 * 24 * time.Hour)
markPresent(t, st, ctx, later)
tl.tick(ctx, later)
if n := countSends(sink, proposalRule); n != 0 {
t.Fatalf("re-announced an existing proposal %d times, want 0", n)
}
}
// TestTickProposalRespectsGate — a proposal is the least urgent thing Maven can
// say, so it is sev1 and the restraint gate suppresses it. Away presence means
// it is not announced at all: it is not held, not retried, it just lives on
// /routines. The proposal row is still written — noticing is never gated.
func TestTickProposalRespectsGate(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
// no presence probes ⇒ away ⇒ care-class gate blocks.
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
tl.tick(ctx, now)
if n := countSends(sink, proposalRule); n != 0 {
t.Fatalf("away: announced %d proposals, want 0", n)
}
if !tl.lastProposalAt.IsZero() {
t.Error("cooldown clock advanced on a suppressed announcement")
}
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
if err != nil {
t.Fatalf("list proposed: %v", err)
}
if len(rows) != 1 {
t.Fatalf("proposed routines = %d, want 1 (detection is never gated)", len(rows))
}
}
// TestTickProposalCooldownSpacesAnnouncements — two patterns detected on the
// same tick must not become two interruptions. The second one waits for the
// cooldown, and is on /routines meanwhile.
func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
for i := 0; i < pattern.MinEvents; i++ {
ts := now.Add(time.Duration(i) * 3 * 24 * time.Hour)
factID, err := st.WriteFact(ctx, ts, store.KindSelf, "litter_box", "clean", "test", 1.0, sql.NullInt64{})
if err != nil {
t.Fatalf("write fact: %v", err)
}
if _, err := st.CreateEvent(ctx, factID, "clean", "litter_box", ts); err != nil {
t.Fatalf("create event: %v", err)
}
}
markPresent(t, st, ctx, now)
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
tl.proposalCfg = &config.PatternProposalConfig{Notify: true, Cooldown: config.Duration(24 * time.Hour)}
tl.tick(ctx, now)
announced := 0
for _, s := range sink.sends {
if strings.HasPrefix(s.RuleName, "proposal:") {
announced++
}
}
if announced != 1 {
t.Fatalf("announced %d proposals on one tick, want exactly 1", announced)
}
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
if err != nil {
t.Fatalf("list proposed: %v", err)
}
if len(rows) != 2 {
t.Fatalf("proposed routines = %d, want 2 (both recorded, one announced)", len(rows))
}
// Still inside the cooldown: silence, even though a proposal is pending.
sink.sends = nil
soon := now.Add(time.Hour)
markPresent(t, st, ctx, soon)
tl.tick(ctx, soon)
for _, s := range sink.sends {
if strings.HasPrefix(s.RuleName, "proposal:") {
t.Fatalf("announced %q inside the cooldown", s.RuleName)
}
}
}
+76 -6
View File
@@ -24,6 +24,7 @@ import (
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/routine"
"github.com/kami/maven/internal/store"
@@ -67,6 +68,14 @@ type tickLoop struct {
morningRoutines []morning.Routine
morningLast map[string]time.Time
// proposalCfg — announcement policy for routines the tick inferred itself.
// nil ⇒ detect silently, never announce (the default). lastProposalAt is
// the cooldown clock, in-memory on purpose: a restart is allowed to permit
// one more announcement, and a restart-per-day loop is a bigger problem
// than a duplicate proposal notice.
proposalCfg *config.PatternProposalConfig
lastProposalAt time.Time
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
// populated when digestCfg != nil && digestCfg.Enabled.
digestQ []QueuedNudge
@@ -92,6 +101,7 @@ func newTickLoop(
digestCfg *config.DigestConfig,
routines []routine.Routine,
morningRoutines []morning.Routine,
proposalCfg *config.PatternProposalConfig,
) *tickLoop {
return &tickLoop{
store: st,
@@ -108,6 +118,7 @@ func newTickLoop(
routineLast: make(map[string]time.Time),
morningRoutines: morningRoutines,
morningLast: make(map[string]time.Time),
proposalCfg: proposalCfg,
lastPhrase: make(map[string]delivery.PhrasedNudge),
}
}
@@ -212,7 +223,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
// path, so a pattern already sitting in history went unnoticed until he
// happened to mention it again by voice. See patterns.go and
// detectPatterns below for how idempotence and dismissal are respected.
t.detectPatterns(ctx, now)
t.detectPatterns(ctx, now, state)
// reminders: gate-bypassing class. fired once, marked after a successful
// delivery. a failed send leaves the reminder pending — the next tick
@@ -381,16 +392,19 @@ func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.St
// resurrecting — there is nothing tick-specific to get right here beyond
// calling the same shared path the voice route already used.
//
// This only ever creates a row for the /routines page to show. It does not
// notify, ring, or speak — Maven is "not a nag, not autonomous" (CLAUDE.md),
// and detection is not the same act as disturbing him about it. A proposal
// only starts producing nudges once he accepts it (fireAcceptedRoutines).
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
// By default this only creates a row for the /routines page to show: it does
// not notify, ring, or speak. Detection is not the same act as disturbing him
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
// is opt-in through the pattern_proposals config block — see announceProposal
// for the restraints that apply even then. A proposal only starts producing
// recurring nudges once he accepts it (fireAcceptedRoutines).
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
pairs, err := t.store.DistinctEventPairs(ctx)
if err != nil {
log.Printf("tick: distinct event pairs: %v", err)
return
}
announced := false
for _, p := range pairs {
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
if err != nil {
@@ -401,9 +415,65 @@ func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
continue // no stable pattern, or already proposed/accepted/dismissed
}
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
// One announcement per tick at most, whatever the scan turned up. The
// rest are on /routines; they are not lost, they are just not shouted.
if announced {
continue
}
announced = t.announceProposal(ctx, r, now, state)
}
}
// announceProposal offers a freshly inferred routine through the ordinary
// care-delivery path, if announcing is switched on at all. Returns true when
// something was actually sent.
//
// Everything here is restraint. The feature is off unless configured; when on
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
// dropped announcement is NOT retried — the cooldown clock advances only on a
// real send, but the proposal row already exists, so the next tick will not
// re-detect it and nothing queues up behind it. A missed announcement means
// he reads it on /routines instead, which is the whole point of the page.
//
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
// an inferred routine cannot arrive worded as something Maven never observed.
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
if !t.proposalCfg.AnnounceProposals() {
return false
}
cooldown := time.Duration(t.proposalCfg.Cooldown)
if cooldown <= 0 {
cooldown = config.DefaultProposalCooldown
}
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
return false
}
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
return false
}
body := pattern.PhraseRoutine(r)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
return false
}
if len(sent) == 0 {
return false // routing dropped it — /routines still has it.
}
t.lastProposalAt = now
return true
}
// digestExpiry — how long a gate-suppressed care nudge stays worth
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
// hour-scale cooldowns and re-derive from facts that reset every day), so a
+2 -2
View File
@@ -46,7 +46,7 @@ func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCf
Nudges: st,
Reminders: st,
})
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil)
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil, nil)
}
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
@@ -63,7 +63,7 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
sink := &fakeSink{}
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil)
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil, nil)
// first tick: seeds, does not fire the routine.
tl.tick(ctx, now)
+5
View File
@@ -26,6 +26,11 @@
"severity_ceiling": 2
},
"pattern_proposals": {
"notify": false,
"cooldown": "24h"
},
"nexus": { "url": "http://nexus:9740" },
"praxis": { "url": "http://praxis:8989" },
"hexis": { "url": "http://hexis:9741" },
+51
View File
@@ -140,6 +140,12 @@ type Config struct {
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
// PatternProposals — whether a routine the digestion tick inferred on its
// own may be announced, and how often. nil / absent ⇒ silent detection
// only: proposals are written for /routines and never announced. See
// PatternProposalConfig.
PatternProposals *PatternProposalConfig `json:"pattern_proposals,omitempty"`
// Praxis — the ecosystem attention-state service. When configured, maven
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
// Maven never touches Praxis's database directly (ecosystem invariant: no
@@ -352,6 +358,40 @@ type DigestConfig struct {
SeverityCeiling int `json:"severity_ceiling,omitempty"` // max sev batched
}
// PatternProposalConfig — announcement policy for routines the digestion tick
// inferred by itself (Vikunja #247, #43).
//
// Detection is always on and always silent by default: the tick writes a
// proposed_routines row and the /routines page shows it. Notify is what turns
// "she noticed" into "she said something", and it is OFF unless configured —
// Maven is not a nag and not autonomous, so a behaviour that speaks without
// being asked has to be switched on deliberately, like weather and telegram.
//
// When Notify is on, the announcement is still heavily restrained:
// - at most one proposal per tick, however many were detected;
// - at most one per Cooldown across all pairs (not per pair), so a batch of
// freshly-detected patterns cannot turn into a queue of interruptions;
// - through the ordinary care-class gate (quiet hours / away / snooze), at
// sev1 — the lowest severity there is. A proposal is the least urgent
// thing Maven can say.
//
// A pair is only ever announced once, because it is only ever proposed once:
// proposed_routines is UNIQUE(action, object) and the row survives dismissal.
type PatternProposalConfig struct {
// Notify — announce newly inferred routines. Default false.
Notify bool `json:"notify,omitempty"`
// Cooldown — minimum spacing between two proposal announcements. 0 ⇒
// DefaultProposalCooldown (24h).
Cooldown Duration `json:"cooldown,omitempty"`
}
// AnnounceProposals reports whether inferred routines may be announced. Safe
// on a nil receiver — an absent config block means silent detection.
func (p *PatternProposalConfig) AnnounceProposals() bool {
return p != nil && p.Notify
}
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
// as a managed subprocess and sends chat-completion requests to phrase nudge
// and reminder messages. nil ⇒ the template-based Stub is used instead.
@@ -448,6 +488,11 @@ const (
DefaultLLMRouter = true
DefaultFactEnrichmentInterval = 30 * time.Second
// DefaultProposalCooldown — one inferred-routine announcement per day at
// most. A proposal is never urgent; if two patterns surface in the same
// hour, the second one waits, and the /routines page has it either way.
DefaultProposalCooldown = 24 * time.Hour
)
// Load reads the JSON config at path and applies defaults. A missing file is
@@ -520,6 +565,12 @@ func (c *Config) applyDefaults() {
c.Digest.SeverityCeiling = 2
}
// Absent block stays nil (⇒ silent detection). Present-but-partial gets the
// cooldown default, so `{"notify": true}` is enough to switch it on.
if c.PatternProposals != nil && c.PatternProposals.Cooldown <= 0 {
c.PatternProposals.Cooldown = Duration(DefaultProposalCooldown)
}
if c.Voice != nil {
if c.Voice.RouterThreshold <= 0 {
c.Voice.RouterThreshold = DefaultRouterThreshold
+13 -3
View File
@@ -20,9 +20,19 @@ type ProposedRoutine struct {
const MaxIntervalRatio = 1.5
// MinEvents is the minimum number of events needed to detect a pattern.
// With N events, there are N-1 intervals; we need at least 2 intervals
// before proposing anything.
const MinEvents = 3
// With N events there are N-1 intervals, so 4 events means 3 intervals.
//
// This used to be 3 (two intervals), which is not a pattern — it is a
// coincidence with a mean. Two gaps of similar length happen constantly:
// water the plants on a Sunday, again the next Sunday, once more the Sunday
// after, and a detector with a ±50% band calls that a weekly routine. The
// cost of being wrong is asymmetric now that the digestion tick scans all of
// history on its own schedule and can announce what it finds: a false
// positive is something the owner has to read and dismiss, and a dismissal
// is permanent, so one bad guess burns that action+object pair forever.
// Three intervals is the cheapest bar that makes a run distinguishable from
// a repeat. False negatives cost one more observation and nothing else.
const MinEvents = 4
// Detect checks whether a sequence of events for the same action+object
// forms a stable recurring pattern. Returns a ProposedRoutine when:
+27 -15
View File
@@ -6,12 +6,13 @@ import (
)
func TestDetectEnoughEvents(t *testing.T) {
// 3 events with 7-day intervals → stable pattern
// MinEvents events with 7-day intervals → stable pattern
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
events := []Event{
{Action: "refill", Object: "cat_water", Ts: base},
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
{Action: "refill", Object: "cat_water", Ts: base.Add(21 * 24 * time.Hour)},
}
r, err := Detect(events)
@@ -24,8 +25,8 @@ func TestDetectEnoughEvents(t *testing.T) {
if r.Action != "refill" || r.Object != "cat_water" {
t.Fatalf("action/object: want refill/cat_water, got %s/%s", r.Action, r.Object)
}
if r.N != 3 {
t.Fatalf("want N=3, got %d", r.N)
if r.N != 4 {
t.Fatalf("want N=4, got %d", r.N)
}
// ~7 days
if r.IntervalDays < 6.9 || r.IntervalDays > 7.1 {
@@ -33,19 +34,28 @@ func TestDetectEnoughEvents(t *testing.T) {
}
}
// TestDetectNotEnoughEvents — two intervals are a coincidence, not a routine
// (Vikunja #43). Three same-day-of-week events used to be enough to propose a
// weekly reminder; MinEvents is 4 now so a repeat has to happen a third time
// before Maven calls it a pattern.
func TestDetectNotEnoughEvents(t *testing.T) {
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
events := []Event{
{Action: "refill", Object: "cat_water", Ts: base},
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
}
r, err := Detect(events)
if err != nil {
t.Fatalf("Detect: %v", err)
}
if r != nil {
t.Fatal("want nil for <3 events")
for _, n := range []int{1, 2, MinEvents - 1} {
events := make([]Event, n)
for i := range events {
events[i] = Event{
Action: "refill",
Object: "cat_water",
Ts: base.Add(time.Duration(i) * 7 * 24 * time.Hour),
}
}
r, err := Detect(events)
if err != nil {
t.Fatalf("Detect(%d events): %v", n, err)
}
if r != nil {
t.Fatalf("Detect(%d events) proposed %+v, want nil below MinEvents=%d", n, r, MinEvents)
}
}
}
@@ -68,12 +78,13 @@ func TestDetectEmpty(t *testing.T) {
}
func TestDetectIrregularRejects(t *testing.T) {
// 3 events but wildly irregular: 1 day, then 14 days → ratio 14 > 1.5
// wildly irregular: 1 day, then 14 days → ratio 14 > 1.5
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
events := []Event{
{Action: "refill", Object: "cat_water", Ts: base},
{Action: "refill", Object: "cat_water", Ts: base.Add(1 * 24 * time.Hour)},
{Action: "refill", Object: "cat_water", Ts: base.Add(15 * 24 * time.Hour)},
{Action: "refill", Object: "cat_water", Ts: base.Add(16 * 24 * time.Hour)},
}
r, err := Detect(events)
@@ -117,6 +128,7 @@ func TestDetectSameTimestamp(t *testing.T) {
{Action: "refill", Object: "cat_water", Ts: base},
{Action: "refill", Object: "cat_water", Ts: base},
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
}
r, err := Detect(events)