569991bb15
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.
213 lines
8.0 KiB
Go
213 lines
8.0 KiB
Go
package pattern
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// ProposedRoutine is a detected recurring pattern that the system wants to
|
|
// suggest as a reminder. Returned by Detect when intervals are stable.
|
|
type ProposedRoutine struct {
|
|
Action string
|
|
Object string
|
|
IntervalDays float64 // median of the on-pattern intervals, in days
|
|
N int // number of events used
|
|
}
|
|
|
|
// MaxIntervalRatio — how far an interval may sit from the median and still
|
|
// count as on-pattern. 1.5 means a 7-day rhythm accepts gaps between ~4.7 and
|
|
// ~10.5 days.
|
|
//
|
|
// It is applied per interval against the MEDIAN, not to the longest/shortest
|
|
// pair. The old extremes test asked "is every gap similar to every other gap",
|
|
// which is a different and much more brittle question: 7, 7, 7, 7, 20 is four
|
|
// clean weeks and one holiday, and max/min = 2.9 threw the whole thing away.
|
|
// One missed week should not erase a habit.
|
|
const MaxIntervalRatio = 1.5
|
|
|
|
// MinOnPatternFraction — how much of the history must sit inside the band
|
|
// before a rhythm is a rhythm. A strict majority: with the median as the
|
|
// centre, half the intervals are inside it by construction, so anything at or
|
|
// below 0.5 would accept noise. 5, 8, 10, 3 has a median of 6.5 and only two
|
|
// of four gaps in band, so it stays what it is — irregular, no routine.
|
|
//
|
|
// At the MinEvents floor (three intervals) 0.7 demands all three, which is
|
|
// right: four events is already the cheapest bar and there is no room in it to
|
|
// also forgive an outlier. Tolerance starts at five intervals, where 4/5 passes.
|
|
const MinOnPatternFraction = 0.7
|
|
|
|
// MinEvents is the minimum number of events needed to detect a pattern.
|
|
// 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
|
|
|
|
// 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
|
|
//
|
|
// The reported IntervalDays is the median of the ON-PATTERN intervals only.
|
|
// Outliers are excluded from the number as well as from the test, so a habit
|
|
// interrupted by a two-week holiday is still reported as weekly rather than as
|
|
// "every 9.6 days" — a figure that describes neither the habit nor the gap.
|
|
//
|
|
// Returns nil when there aren't enough events or the intervals are too
|
|
// irregular — false negatives are harmless. The only dangerous mistake
|
|
// is a false positive, and this detector makes none: the confirmation
|
|
// gate (voice park or web page) catches any we do produce.
|
|
func Detect(events []Event) (*ProposedRoutine, error) {
|
|
if len(events) < MinEvents {
|
|
return nil, nil // not enough data
|
|
}
|
|
|
|
nIntervals := len(events) - 1
|
|
intervals := make([]float64, nIntervals)
|
|
|
|
for i := 0; i < nIntervals; i++ {
|
|
diff := events[i+1].Ts.Sub(events[i].Ts)
|
|
days := diff.Hours() / 24.0
|
|
if days <= 0 {
|
|
// Two events at the same timestamp — can't compute a meaningful
|
|
// interval. Skip this candidate silently.
|
|
return nil, nil
|
|
}
|
|
intervals[i] = days
|
|
}
|
|
|
|
center := medianFloat(intervals)
|
|
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
|
|
// bound is symmetric in ratio terms, not in days: half the median below,
|
|
// the median times the ratio above.
|
|
var onPattern []float64
|
|
for _, d := range intervals {
|
|
if d <= center*MaxIntervalRatio && d >= center/MaxIntervalRatio {
|
|
onPattern = append(onPattern, d)
|
|
}
|
|
}
|
|
if float64(len(onPattern))/float64(nIntervals) < MinOnPatternFraction {
|
|
return nil, nil // too irregular
|
|
}
|
|
|
|
return &ProposedRoutine{
|
|
Action: events[0].Action,
|
|
Object: events[0].Object,
|
|
IntervalDays: math.Round(medianFloat(onPattern)*10) / 10, // round to 1 decimal
|
|
N: len(events),
|
|
}, nil
|
|
}
|
|
|
|
// medianFloat — the middle value, averaging the two middles on an even count.
|
|
// Sorts a copy: the caller's interval order is the event order and stays that
|
|
// way.
|
|
func medianFloat(xs []float64) float64 {
|
|
if len(xs) == 0 {
|
|
return 0
|
|
}
|
|
s := make([]float64, len(xs))
|
|
copy(s, xs)
|
|
sort.Float64s(s)
|
|
mid := len(s) / 2
|
|
if len(s)%2 == 1 {
|
|
return s[mid]
|
|
}
|
|
return (s[mid-1] + s[mid]) / 2
|
|
}
|
|
|
|
// PhraseRoutine generates a human-readable suggestion string for a
|
|
// detected routine. Returns a Russian phrase like
|
|
// "ты заправляешь поилку раз в 7 дней — напоминать?"
|
|
func PhraseRoutine(p *ProposedRoutine) string {
|
|
actionWord := p.Action
|
|
objectWord := p.Object
|
|
|
|
days := int(math.Round(p.IntervalDays))
|
|
// Russian grammatical gender/hardcoded — matches maven's existing persona.
|
|
var intervalPhrase string
|
|
switch {
|
|
case days < 1:
|
|
intervalPhrase = "каждый день"
|
|
case days == 1:
|
|
intervalPhrase = "каждый день"
|
|
case days < 7:
|
|
intervalPhrase = fmt.Sprintf("раз в %d дня", days)
|
|
if days%10 == 1 && days%100 != 11 {
|
|
intervalPhrase = fmt.Sprintf("раз в %d день", days)
|
|
}
|
|
case days == 7:
|
|
intervalPhrase = "раз в неделю"
|
|
case days%7 == 0:
|
|
intervalPhrase = fmt.Sprintf("раз в %d недели", days/7)
|
|
if (days/7)%10 == 1 && (days/7)%100 != 11 {
|
|
intervalPhrase = fmt.Sprintf("раз в %d неделю", days/7)
|
|
}
|
|
case days < 30:
|
|
intervalPhrase = fmt.Sprintf("раз в %d дней", days)
|
|
default:
|
|
intervalPhrase = fmt.Sprintf("каждые %d дней", days)
|
|
}
|
|
|
|
objectDisplay := strings.ReplaceAll(objectWord, "_", " ")
|
|
return fmt.Sprintf("ты %s %s %s — напоминать?", actionVerb(actionWord), objectDisplay, intervalPhrase)
|
|
}
|
|
|
|
// actionVerb returns a conjugated Russian verb form for "you do" (ты-form).
|
|
func actionVerb(action string) string {
|
|
switch action {
|
|
case "refill":
|
|
return "заправляешь"
|
|
case "feed":
|
|
return "кормишь"
|
|
case "change":
|
|
return "меняешь"
|
|
case "clean":
|
|
return "чистишь"
|
|
case "take":
|
|
return "принимаешь"
|
|
case "walk":
|
|
return "выгуливаешь"
|
|
case "water":
|
|
return "поливаешь"
|
|
default:
|
|
return action + " (делаешь)"
|
|
}
|
|
}
|