From 766ca091a75aa1490af200431bc45e08dbed19e0 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 01:37:50 +0400 Subject: [PATCH] Announce tick-inferred routines, opt-in and rate-limited (#247, #43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/mavend/main.go | 4 +- cmd/mavend/patterns_test.go | 185 ++++++++++++++++++++++++++++-- cmd/mavend/tick.go | 82 ++++++++++++- cmd/mavend/tick_test.go | 4 +- deploy/mavend.json | 5 + internal/config/config.go | 51 ++++++++ internal/pattern/detector.go | 16 ++- internal/pattern/detector_test.go | 42 ++++--- 8 files changed, 350 insertions(+), 39 deletions(-) diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 58ef334..5fa9463 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -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. diff --git a/cmd/mavend/patterns_test.go b/cmd/mavend/patterns_test.go index db2faf5..1b14524 100644 --- a/cmd/mavend/patterns_test.go +++ b/cmd/mavend/patterns_test.go @@ -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) + } + } +} diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index 47b97ff..cc2f991 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -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 diff --git a/cmd/mavend/tick_test.go b/cmd/mavend/tick_test.go index 3333ce8..69dfcbd 100644 --- a/cmd/mavend/tick_test.go +++ b/cmd/mavend/tick_test.go @@ -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) diff --git a/deploy/mavend.json b/deploy/mavend.json index 962e47d..ed3b9bc 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -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" }, diff --git a/internal/config/config.go b/internal/config/config.go index 33f1f26..b28b228 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/pattern/detector.go b/internal/pattern/detector.go index ffb92c2..8c19775 100644 --- a/internal/pattern/detector.go +++ b/internal/pattern/detector.go @@ -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: diff --git a/internal/pattern/detector_test.go b/internal/pattern/detector_test.go index c50d2eb..0e24f8e 100644 --- a/internal/pattern/detector_test.go +++ b/internal/pattern/detector_test.go @@ -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)