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 + " (делаешь)" } }