Compare commits

..

3 Commits

Author SHA1 Message Date
claude 569991bb15 pattern: a burst of taps is not a routine (V-468)
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.
2026-08-04 03:00:11 +04:00
claude c915115096 eval: a verb governed by "ты" is his, not her drift (V-462)
CheckFeminine flagged "ты заплатил за домен" as a masculine self-reference.
The second pass reads a masculine past-tense verb before "тебе", "тебя" or
"за" as her speaking with the pronoun dropped, and it checked neither the
subject nor what "за" pointed at. He is male, so a verb governed by "ты"
must be masculine, and "за домен" is a price rather than a favour.

The talk fixture was under-reporting by a point whenever a reply addressed
him in the past tense, which is common.
2026-08-04 02:58:19 +04:00
claude 908d92a7e8 calendar: a Russian summary keeps its letters in the fact key (V-443)
safeKey kept ASCII only, so "Встреча с Аней" and "Обед с мамой" both
reduced to "--" and shared one key on one day. The second event of the
day overwrote the first, silently, and his calendar is Russian.

Letters and digits in any script now pass. Migration #18 deletes the rows
written under the old rule instead of rewriting them: a calendar fact is
derived, the next poll writes the day again, and a stale row reads as an
extra meeting.
2026-08-04 02:56:09 +04:00
8 changed files with 176 additions and 8 deletions
+11 -4
View File
@@ -18,6 +18,7 @@ import (
"sort"
"strings"
"time"
"unicode"
)
// Fact sources. A calendar event reaches the store as a
@@ -153,14 +154,20 @@ func Overlapping(events []Event, from, to time.Time) []Event {
return out
}
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
// the day prefix carries the identity and this only disambiguates within a day.
// safeKey makes a summary safe to use inside a fact key: letters and digits in
// any script, plus dashes, with space and underscore folded to a dash.
//
// It kept ASCII only until 04-08-2026, and dropped everything else. His
// calendar is Russian, so "Встреча с Аней" and "Обед с мамой" both reduced to
// "--" and produced the same key on the same day — the second event of the day
// silently overwrote the first (Vikunja #443). Letting the letters through is
// what makes the key identify the event. Migration #18 drops the keys written
// under the old rule; they are re-derived on the next poll.
func safeKey(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-':
b.WriteRune(r)
case r == ' ' || r == '_':
b.WriteRune('-')
+19
View File
@@ -139,6 +139,9 @@ func TestSafeKey(t *testing.T) {
{"Hello_World", "Hello-World"},
{"special@#$chars!!", "specialchars"},
{"ALL_CAPS_123", "ALL-CAPS-123"},
// His calendar is Russian. These reduced to "--" and "--" (Vikunja #443).
{"Встреча с Аней", "Встреча-с-Аней"},
{"Обед с мамой", "Обед-с-мамой"},
}
for _, tt := range tests {
if got := safeKey(tt.in); got != tt.want {
@@ -263,3 +266,19 @@ func TestSourceTrust(t *testing.T) {
t.Errorf("Sources() = %v", Sources())
}
}
// Two Russian events on one day must not share a key. They did: safeKey kept
// ASCII only, so both summaries collapsed to their spaces and the second event
// overwrote the first in the store (Vikunja #443).
func TestFactKeyDistinguishesRussianEventsOnOneDay(t *testing.T) {
day := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
a := Event{Summary: "Встреча с Аней", Start: day.Add(10 * time.Hour), End: day.Add(11 * time.Hour)}
b := Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(14 * time.Hour)}
if FactKeyIn(a, time.UTC) == FactKeyIn(b, time.UTC) {
t.Fatalf("both events keyed as %q", FactKeyIn(a, time.UTC))
}
// The day prefix still has to survive, because the store range-scans on it.
if !strings.HasPrefix(FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) {
t.Fatalf("key %q lost the day prefix %q", FactKeyIn(a, time.UTC), KeyPrefixForDay(day))
}
}
+24 -2
View File
@@ -53,9 +53,31 @@ const MinOnPatternFraction = 0.7
// 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
//
@@ -88,8 +110,8 @@ func Detect(events []Event) (*ProposedRoutine, error) {
}
center := medianFloat(intervals)
if center <= 0 {
return nil, nil
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
+43
View File
@@ -216,3 +216,46 @@ 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")
}
}
+29 -2
View File
@@ -173,12 +173,23 @@ func checkFeminine(body string) Result {
// Second pass: self-reference with the pronoun dropped — "напомнил тебе",
// "проверил за тебя". A masculine past-tense verb whose object is HIM can
// 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 {
if !masculinePast(w) || i+1 >= len(words) {
if !masculinePast(w) || i+1 >= len(words) || governedByYou(words, i) {
continue
}
next := words[i+1]
if next == "тебе" || next == "тебя" || next == "за" {
aboutHim := next == "тебе" || next == "тебя"
if next == "за" && i+2 < len(words) && (words[i+2] == "тебя" || words[i+2] == "тебе") {
aboutHim = true
}
if aboutHim {
return Result{CheckFeminine, false,
fmt.Sprintf("masculine self-reference %q before %q", w, next)}
}
@@ -652,3 +663,19 @@ func checkEllipsis(body string) Result {
}
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
}
+6
View File
@@ -106,6 +106,12 @@ func TestChecksCatchWhatTheyClaim(t *testing.T) {
{"masculine predicative", "я должен сказать: попей воды.", CheckFeminine},
// The other direction: HE is male, so second-person masculine is right.
{"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.
{"feminine second person", "ты давно не отдыхала — попей воды.", CheckHisGender},
{"feminine second person no dash", "ты пила воду четыре часа назад.", CheckHisGender},
+11
View File
@@ -208,6 +208,17 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
// list_tasks into something that writes without the row changing by one
// byte. The fingerprint is the declared shape at approval time, so a
// redefinition is a re-approval instead of a silent upgrade.
`DELETE FROM facts
WHERE key LIKE 'calendar_event_%'
AND replace(substr(key, 25), '-', '') = '';`,
// #18 — drop the calendar keys written while safeKey dropped Cyrillic
// (Vikunja #443). Everything after the date prefix was punctuation, so
// every Russian event on one day shared one key and only the last one
// survived. Deleting rather than rewriting: a calendar fact is derived
// data, the next poll writes the day again under keys that identify the
// event, and the old rows would otherwise be recited as extra meetings.
// The filter is exact — it keeps any key whose summary part still has a
// letter or a digit in it.
}
// migrate applies every migration with a number greater than the DB's current
+33
View File
@@ -47,3 +47,36 @@ func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
t.Fatalf("after re-migrate user_version = %d, want %d", v, want)
}
}
// Migration #18 clears the calendar keys written while safeKey dropped
// Cyrillic. Those rows are indistinguishable from real events on read, so
// leaving them would recite one meeting as several (Vikunja #443).
func TestCollapsedCalendarKeysAreDropped(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
rows := []string{
"calendar_event_20260804_--", // "Встреча с Аней" under the old rule
"calendar_event_20260804_", // a one-word Russian summary
"calendar_event_20260804_Встреча-с-Аней", // the new format
"calendar_event_20260804_Standup", // an ASCII summary, always fine
}
for _, key := range rows {
if _, err := s.db.ExecContext(ctx,
`INSERT INTO facts (ts, kind, key, value, source, confidence) VALUES (0, 'env', ?, 'x', 'poll:caldav', 1.0)`,
key); err != nil {
t.Fatalf("seed %q: %v", key, err)
}
}
if _, err := s.db.ExecContext(ctx, migrations[17]); err != nil {
t.Fatalf("migration 18: %v", err)
}
var got int
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM facts WHERE key LIKE 'calendar_event_%'`).Scan(&got); err != nil {
t.Fatal(err)
}
if got != 2 {
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
}
}