Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcda5e3d2c | |||
| bdafc82e35 | |||
| c69023c310 |
@@ -53,31 +53,9 @@ 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
|
||||||
//
|
//
|
||||||
@@ -110,8 +88,8 @@ func Detect(events []Event) (*ProposedRoutine, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
center := medianFloat(intervals)
|
center := medianFloat(intervals)
|
||||||
if center <= 0 || center < MinIntervalDays {
|
if center <= 0 {
|
||||||
return nil, nil // a burst, not a rhythm — see MinIntervalDays
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,46 +216,3 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -173,23 +173,12 @@ func checkFeminine(body string) Result {
|
|||||||
// Second pass: self-reference with the pronoun dropped — "напомнил тебе",
|
// Second pass: self-reference with the pronoun dropped — "напомнил тебе",
|
||||||
// "проверил за тебя". A masculine past-tense verb whose object is HIM can
|
// "проверил за тебя". A masculine past-tense verb whose object is HIM can
|
||||||
// only be her speaking about herself.
|
// only be her speaking about herself.
|
||||||
//
|
|
||||||
// Two guards, both from a false positive on the talk fixture: "ты заплатил
|
|
||||||
// за домен до марта" scored as her drift and cost the run a point it had
|
|
||||||
// earned (Vikunja #462). He is male, so a past-tense verb governed by "ты"
|
|
||||||
// must be masculine. And a bare "за" is not evidence of anything — "за
|
|
||||||
// домен" is a price, "за тебя" is her doing something on his behalf — so it
|
|
||||||
// only counts when he is the one it points at.
|
|
||||||
for i, w := range words {
|
for i, w := range words {
|
||||||
if !masculinePast(w) || i+1 >= len(words) || governedByYou(words, i) {
|
if !masculinePast(w) || i+1 >= len(words) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
next := words[i+1]
|
next := words[i+1]
|
||||||
aboutHim := next == "тебе" || next == "тебя"
|
if next == "тебе" || next == "тебя" || next == "за" {
|
||||||
if next == "за" && i+2 < len(words) && (words[i+2] == "тебя" || words[i+2] == "тебе") {
|
|
||||||
aboutHim = true
|
|
||||||
}
|
|
||||||
if aboutHim {
|
|
||||||
return Result{CheckFeminine, false,
|
return Result{CheckFeminine, false,
|
||||||
fmt.Sprintf("masculine self-reference %q before %q", w, next)}
|
fmt.Sprintf("masculine self-reference %q before %q", w, next)}
|
||||||
}
|
}
|
||||||
@@ -663,19 +652,3 @@ func checkEllipsis(body string) Result {
|
|||||||
}
|
}
|
||||||
return Result{CheckEllipsis, true, ""}
|
return Result{CheckEllipsis, true, ""}
|
||||||
}
|
}
|
||||||
|
|
||||||
// governedByYou reports whether "ты" stands close enough in front of the verb
|
|
||||||
// at index i to be its subject. Three words, the same window checkFeminine's
|
|
||||||
// first pass uses after "я", and it stops at a first-person pronoun so "ты
|
|
||||||
// просил, я напомнил" still trips.
|
|
||||||
func governedByYou(words []string, i int) bool {
|
|
||||||
for j := i - 1; j >= 0 && j >= i-3; j-- {
|
|
||||||
switch words[j] {
|
|
||||||
case "ты":
|
|
||||||
return true
|
|
||||||
case "я":
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -106,12 +106,6 @@ func TestChecksCatchWhatTheyClaim(t *testing.T) {
|
|||||||
{"masculine predicative", "я должен сказать: попей воды.", CheckFeminine},
|
{"masculine predicative", "я должен сказать: попей воды.", CheckFeminine},
|
||||||
// The other direction: HE is male, so second-person masculine is right.
|
// The other direction: HE is male, so second-person masculine is right.
|
||||||
{"second person masculine ok", "ты не пил воду четыре часа.", ""},
|
{"second person masculine ok", "ты не пил воду четыре часа.", ""},
|
||||||
// The recorded false positive: "заплатил" sits before "за", and the
|
|
||||||
// second pass read that as her dropping the pronoun. The subject is
|
|
||||||
// "ты" and he is male, so the reply is right (Vikunja #462).
|
|
||||||
{"second person masculine before за", "ты заплатил за домен до марта, а воду пить всё равно надо.", ""},
|
|
||||||
// The same shape she really does get wrong still trips.
|
|
||||||
{"masculine on his behalf", "проверил за тебя — воды не было четыре часа.", CheckFeminine},
|
|
||||||
// The real observed failure: she addressed him as a woman.
|
// The real observed failure: she addressed him as a woman.
|
||||||
{"feminine second person", "ты давно не отдыхала — попей воды.", CheckHisGender},
|
{"feminine second person", "ты давно не отдыхала — попей воды.", CheckHisGender},
|
||||||
{"feminine second person no dash", "ты пила воду четыре часа назад.", CheckHisGender},
|
{"feminine second person no dash", "ты пила воду четыре часа назад.", CheckHisGender},
|
||||||
|
|||||||
Reference in New Issue
Block a user