Files
Maven/internal/pattern/detector_test.go
claude 569991bb15 pattern: a burst of taps is not a routine (V-468)
Detect had no floor on the interval. Four events minutes apart give gaps
near 0.002 days, every one of them inside the ±50% band, so it proposed a
routine and PhraseRoutine called it "каждый день".

UNIQUE(action, object) makes that unrecoverable: dismissing the bogus
proposal burns the pair, and the real routine behind it can never be
proposed again. It also made hand-QA unsafe — seeding a pattern with four
chat turns poisoned the pair being tested.

The floor is two hours against the median, not a day, because meals, water
and breaks are genuine several-times-a-day habits.
2026-08-04 03:00:11 +04:00

262 lines
8.3 KiB
Go

package pattern
import (
"testing"
"time"
)
func TestDetectEnoughEvents(t *testing.T) {
// 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)
if err != nil {
t.Fatalf("Detect: %v", err)
}
if r == nil {
t.Fatal("want a proposed routine, got nil")
}
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 != 4 {
t.Fatalf("want N=4, got %d", r.N)
}
// ~7 days
if r.IntervalDays < 6.9 || r.IntervalDays > 7.1 {
t.Fatalf("want interval ~7, got %f", r.IntervalDays)
}
}
// 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)
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)
}
}
}
func TestDetectEmpty(t *testing.T) {
r, err := Detect(nil)
if err != nil {
t.Fatalf("Detect: %v", err)
}
if r != nil {
t.Fatal("want nil for empty events")
}
r, err = Detect([]Event{})
if err != nil {
t.Fatalf("Detect: %v", err)
}
if r != nil {
t.Fatal("want nil for empty events")
}
}
func TestDetectIrregularRejects(t *testing.T) {
// 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)
if err != nil {
t.Fatalf("Detect: %v", err)
}
if r != nil {
t.Fatal("want nil for irregular intervals (ratio 14 > 1.5)")
}
}
func TestDetectBarelyStable(t *testing.T) {
// 4 events, intervals vary but within 1.5 ratio
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
events := []Event{
{Action: "feed", Object: "cat", Ts: base},
{Action: "feed", Object: "cat", Ts: base.Add(6 * 24 * time.Hour)}, // 6 days
{Action: "feed", Object: "cat", Ts: base.Add(12 * 24 * time.Hour)}, // 6 days
{Action: "feed", Object: "cat", Ts: base.Add(20 * 24 * time.Hour)}, // 8 days
}
r, err := Detect(events)
if err != nil {
t.Fatalf("Detect: %v", err)
}
if r == nil {
t.Fatal("want proposed routine for barely stable intervals (8/6=1.33 ≤ 1.5)")
}
if r.Action != "feed" || r.Object != "cat" {
t.Fatalf("action/object mismatch")
}
if r.N != 4 {
t.Fatalf("want N=4, got %d", r.N)
}
}
func TestDetectSameTimestamp(t *testing.T) {
// Two events at the same time — meaningless interval, should be ignored
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},
{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)
if err != nil {
t.Fatalf("Detect: %v", err)
}
if r != nil {
t.Fatal("want nil when first two events have same timestamp")
}
}
func TestPhraseRoutine(t *testing.T) {
tests := []struct {
r ProposedRoutine
want string
}{
{ProposedRoutine{Action: "refill", Object: "cat_water", IntervalDays: 7}, "ты заправляешь cat water раз в неделю — напоминать?"},
{ProposedRoutine{Action: "feed", Object: "cat", IntervalDays: 1}, "ты кормишь cat каждый день — напоминать?"},
{ProposedRoutine{Action: "clean", Object: "litter_box", IntervalDays: 3}, "ты чистишь litter box раз в 3 дня — напоминать?"},
{ProposedRoutine{Action: "take", Object: "medicine", IntervalDays: 0.5}, "ты принимаешь medicine каждый день — напоминать?"},
{ProposedRoutine{Action: "walk", Object: "dog", IntervalDays: 14}, "ты выгуливаешь dog раз в 2 недели — напоминать?"},
}
for _, tc := range tests {
t.Run(tc.r.Action+"_"+tc.r.Object, func(t *testing.T) {
got := PhraseRoutine(&tc.r)
if got != tc.want {
t.Fatalf("phrase: want %q, got %q", tc.want, got)
}
})
}
}
// evAt builds a run of events at the given day offsets.
func evAt(offsets ...float64) []Event {
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
out := make([]Event, len(offsets))
for i, d := range offsets {
out[i] = Event{Action: "refill", Object: "cat_water",
Ts: base.Add(time.Duration(d * float64(24*time.Hour)))}
}
return out
}
// TestDetectMedianBandNotExtremes — the stability test used to be
// longest/shortest, so a single outlier vetoed an otherwise clean rhythm and
// the reported interval was a mean dragged toward that outlier. Both are
// median-based now.
func TestDetectMedianBandNotExtremes(t *testing.T) {
cases := []struct {
name string
days []float64
want float64 // 0 means "expect no routine"
}{
// Four clean weeks and one holiday. max/min was 20/7 = 2.9, rejected.
{"weekly with one long gap", []float64{0, 7, 14, 21, 28, 48}, 7},
// The reviewer's case: 5, 8, 10, 3. Median 6.5, only two gaps in band.
{"genuinely irregular", []float64{0, 5, 13, 23, 26}, 0},
// A short gap outlier is treated the same as a long one.
{"weekly with one short gap", []float64{0, 7, 14, 15, 22, 29}, 7},
// Two outliers out of five is past the fraction.
{"too many outliers", []float64{0, 7, 14, 34, 41, 61}, 0},
// At the MinEvents floor there is no outlier budget at all.
{"floor rejects one outlier", []float64{0, 7, 14, 34}, 0},
{"floor accepts a clean run", []float64{0, 7, 14, 21}, 7},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r, err := Detect(evAt(tc.days...))
if err != nil {
t.Fatalf("Detect: %v", err)
}
if tc.want == 0 {
if r != nil {
t.Fatalf("want no routine, got interval %.1f", r.IntervalDays)
}
return
}
if r == nil {
t.Fatal("want a routine, got nil")
}
if r.IntervalDays != tc.want {
t.Fatalf("interval: want %.1f, got %.1f", tc.want, r.IntervalDays)
}
})
}
}
// 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")
}
}