package pattern import ( "fmt" "math" "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 // mean interval in days (float for sub-day precision) N int // number of events used } // MaxIntervalRatio is the maximum ratio between the longest and shortest // interval for a pattern to be considered stable. ±50% variance allowed. const MaxIntervalRatio = 1.5 // 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 // 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 (≥2 intervals) // - The ratio longest/shortest interval ≤ MaxIntervalRatio // // 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) var sum float64 var min float64 = math.MaxFloat64 var max float64 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 sum += days if days < min { min = days } if days > max { max = days } } // Stability check: the most extreme intervals shouldn't differ by // more than MaxIntervalRatio. A ratio of 1.5 means a 7-day pattern // can have intervals between ~5.6 and ~8.4 days. if min > 0 && max/min > MaxIntervalRatio { return nil, nil // too irregular } mean := sum / float64(nIntervals) return &ProposedRoutine{ Action: events[0].Action, Object: events[0].Object, IntervalDays: math.Round(mean*10) / 10, // round to 1 decimal N: len(events), }, nil } // 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 + " (делаешь)" } }