diff --git a/internal/pattern/detector.go b/internal/pattern/detector.go index 741d81f..cdaaf8d 100644 --- a/internal/pattern/detector.go +++ b/internal/pattern/detector.go @@ -53,9 +53,31 @@ const MinOnPatternFraction = 0.7 // a repeat. False negatives cost one more observation and nothing else. const MinEvents = 4 +// MinIntervalDays — the fastest rhythm that may be called a routine. Two +// hours. +// +// Without a floor, four taps of the same key minutes apart give intervals near +// 0.002 days. They all sit inside the ±50% band by construction, so the +// detector proposed a routine and PhraseRoutine worded it as "каждый день" +// (Vikunja #468). The damage outlives the mistake: UNIQUE(action, object) +// means dismissing the bogus proposal burns that pair permanently, so the real +// routine behind it can never be proposed again. +// +// Two hours rather than a day, because a genuine habit can run several times a +// day — meals, water, a break. Anything faster than that is not a habit she +// should be proposing to remind him about; the loop rules already cover that +// range, and they are rules, not guesses. It is checked against the median, so +// one quick repeat inside a real rhythm still counts. +// +// The other half of this is that hand-QA of the detector was unsafe: seeding a +// pattern the obvious way, four chat turns in a row, poisoned the very pair +// being tested. +const MinIntervalDays = 2.0 / 24.0 + // Detect checks whether a sequence of events for the same action+object // forms a stable recurring pattern. Returns a ProposedRoutine when: // - At least MinEvents events exist (≥3 intervals) +// - The median interval is at least MinIntervalDays // - At least MinOnPatternFraction of the intervals sit within // MaxIntervalRatio of the median interval // @@ -88,8 +110,8 @@ func Detect(events []Event) (*ProposedRoutine, error) { } center := medianFloat(intervals) - if center <= 0 { - return nil, nil + if center <= 0 || center < MinIntervalDays { + return nil, nil // a burst, not a rhythm — see MinIntervalDays } // Keep the intervals that sit inside the band around the median. The diff --git a/internal/pattern/detector_test.go b/internal/pattern/detector_test.go index 056a3f6..88fb4de 100644 --- a/internal/pattern/detector_test.go +++ b/internal/pattern/detector_test.go @@ -216,3 +216,46 @@ func TestDetectMedianBandNotExtremes(t *testing.T) { }) } } + +// A burst is not a habit. Four taps of the same key minutes apart give +// intervals near 0.002 days, all inside the ±50% band by construction, so the +// detector called it a daily routine (Vikunja #468). Dismissing that proposal +// burns the action+object pair permanently, which also made hand-QA of the +// detector unsafe. +func TestDetectRejectsABurst(t *testing.T) { + base := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) + var events []Event + for i := 0; i < 4; i++ { + events = append(events, Event{ + Action: "refill", Object: "cat_water", + Ts: base.Add(time.Duration(i) * 7 * time.Minute), + }) + } + r, err := Detect(events) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if r != nil { + t.Fatalf("four taps minutes apart proposed a routine every %.3f days", r.IntervalDays) + } +} + +// The floor is two hours, not a day: a habit that runs several times a day is +// still a habit. +func TestDetectKeepsASeveralTimesADayHabit(t *testing.T) { + base := time.Date(2026, 8, 4, 8, 0, 0, 0, time.UTC) + var events []Event + for i := 0; i < 5; i++ { + events = append(events, Event{ + Action: "drink", Object: "water", + Ts: base.Add(time.Duration(i) * 4 * time.Hour), + }) + } + r, err := Detect(events) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if r == nil { + t.Fatal("a four-hour rhythm over five events is a habit, got nil") + } +}