Address PR review comments on 50, 52, 53, 54, 59, 61
Seven fixes, each answering a line comment on the stack.
**Weather no longer invents Moscow** (PR 50). extractWeatherLocation returned
the string "Moscow" when he named no city and voice.weather.default_location
was unset — a made-up answer presented as fact, which is the one thing maven
must never do. It returns "" now and the query path says it does not know.
**Digest statuses are a defined type** (PR 50). DigestStatus string plus the
three constants, so a rule name cannot reach the status column.
**Quiet-mode negation is not adjacency** (PR 53). The OFF list carried
{"не","тих"}, an adjacency pattern, so "не надо тихий режим" missed OFF, hit
the ON pattern {"тих","режим"}, and asking for quiet mode to stop turned it
on. Negators are scanned over the whole utterance now, with the two ON phrases
that are themselves built on "не" excluded. "тихий режим выключи" works too,
which it did not before.
**Pattern stability uses a median band** (PR 54). max/min over the extremes
asked whether every gap resembles every other gap, so 7,7,7,7,20 — four clean
weeks and one holiday — was thrown away at a ratio of 2.9. Each interval is
now tested against the median and 70% must be in band, and the reported
interval is the median of the in-band ones, so a holiday no longer drags a
weekly habit to "every 9.6 days". The reviewer's 5,8,10,3 is still rejected.
**The weekday profile stops reciting everyday habits** (PR 59). "What do I do
on Saturdays?" answered "you drink water" — true, and useless, because it is
equally true of every other day. Activities that are habits on six or more
weekdays move to Profile.Everyday and are read back as daily habits instead of
as an answer about that day.
**Russian phrase tables move out of Go** (PR 59, PR 61). The behaviour glosses
and weekday names, and the task capture/urgency/list vocabulary, are now
behavior_ru.json and task_phrases.json, embedded with go:embed. Single-binary
deploy is unchanged; wording edits are no longer source diffs.
**nginx template stops taking nginx down** (PR 52). Two host-side failure
modes, both plausible causes of today's crash. The $connection_upgrade map is
fatal when duplicated, so it moved to its own nginx-upgrade-map.conf with a
grep-first note. And `listen 10.42.0.1:80` fails with EADDRNOTAVAIL when wg0
is not up yet, so nginx exits on a reboot that beats WireGuard — the header
now documents net.ipv4.ip_nonlocal_bind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
@@ -3,6 +3,7 @@ package pattern
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -11,14 +12,32 @@ import (
|
||||
type ProposedRoutine struct {
|
||||
Action string
|
||||
Object string
|
||||
IntervalDays float64 // mean interval in days (float for sub-day precision)
|
||||
IntervalDays float64 // median of the on-pattern intervals, in days
|
||||
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.
|
||||
// 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.
|
||||
//
|
||||
@@ -36,8 +55,14 @@ 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
|
||||
// - At least MinEvents events exist (≥3 intervals)
|
||||
// - 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
|
||||
@@ -51,10 +76,6 @@ func Detect(events []Event) (*ProposedRoutine, error) {
|
||||
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
|
||||
@@ -64,32 +85,51 @@ func Detect(events []Event) (*ProposedRoutine, error) {
|
||||
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 {
|
||||
center := medianFloat(intervals)
|
||||
if center <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
mean := sum / float64(nIntervals)
|
||||
|
||||
return &ProposedRoutine{
|
||||
Action: events[0].Action,
|
||||
Object: events[0].Object,
|
||||
IntervalDays: math.Round(mean*10) / 10, // round to 1 decimal
|
||||
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 дней — напоминать?"
|
||||
|
||||
@@ -161,3 +161,58 @@ func TestPhraseRoutine(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user