Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 569991bb15 |
@@ -53,9 +53,31 @@ const MinOnPatternFraction = 0.7
|
|||||||
// a repeat. False negatives cost one more observation and nothing else.
|
// a repeat. False negatives cost one more observation and nothing else.
|
||||||
const MinEvents = 4
|
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
|
// Detect checks whether a sequence of events for the same action+object
|
||||||
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
||||||
// - At least MinEvents events exist (≥3 intervals)
|
// - At least MinEvents events exist (≥3 intervals)
|
||||||
|
// - The median interval is at least MinIntervalDays
|
||||||
// - At least MinOnPatternFraction of the intervals sit within
|
// - At least MinOnPatternFraction of the intervals sit within
|
||||||
// MaxIntervalRatio of the median interval
|
// MaxIntervalRatio of the median interval
|
||||||
//
|
//
|
||||||
@@ -88,8 +110,8 @@ func Detect(events []Event) (*ProposedRoutine, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
center := medianFloat(intervals)
|
center := medianFloat(intervals)
|
||||||
if center <= 0 {
|
if center <= 0 || center < MinIntervalDays {
|
||||||
return nil, nil
|
return nil, nil // a burst, not a rhythm — see MinIntervalDays
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the intervals that sit inside the band around the median. The
|
// Keep the intervals that sit inside the band around the median. The
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user