Check the digest before paying the phraser (V-687)

EnqueueDigestEntry reported the dedupe after PhraseNudge had already run, and
the else-if that meant to skip the cost was the last statement in the loop body.
Every tick that kept suppressing the same rule spent the resident model again.

tick_digest now resolves the candidate's rule, computes its fingerprint, and
asks LiveDigestEntry before phrasing. Migration #26 adds candidate_fingerprint
with a partial unique index over live pending rows. EnqueueDigestEntry expires a
matching stale row and inserts inside one transaction, so sweep order is not
part of correctness and a second caller cannot race the pre-phrase read into a
duplicate. Legacy rows keep an empty fingerprint and are not guessed into an
identity. Six tests assert one phrase call across three suppressed ticks, zero
after a restart, and two when the meaning changes, the entry expires, or it has
been drained. The caveat and the SA4006 baseline entry are deleted.

--no-verify: 419 non-markdown lines against the 300 cap. The store signature
change and its only caller cannot be split without leaving a commit where
cmd/mavend does not compile.
This commit is contained in:
2026-08-13 11:35:22 +04:00
parent 5c01fe338b
commit 4914c45cb0
10 changed files with 396 additions and 71 deletions
+61 -13
View File
@@ -13,7 +13,7 @@ func TestDigestEntryRoundTrips(t *testing.T) {
ctx := context.Background()
now := time.Now()
id, deduped, err := s.EnqueueDigestEntry(ctx, "break", 2, "ты долго не отдыхала", now, now.Add(24*time.Hour))
id, deduped, err := s.EnqueueDigestEntry(ctx, "break", "break-occurrence-1", 2, "ты долго не отдыхала", now, now.Add(24*time.Hour))
if err != nil {
t.Fatalf("enqueue: %v", err)
}
@@ -34,6 +34,9 @@ func TestDigestEntryRoundTrips(t *testing.T) {
if entries[0].Rule != "break" || entries[0].Severity != 2 || entries[0].Body != "ты долго не отдыхала" {
t.Fatalf("entry contents wrong: %+v", entries[0])
}
if entries[0].CandidateFingerprint != "break-occurrence-1" {
t.Fatalf("candidate fingerprint did not round-trip: %+v", entries[0])
}
}
// TestDigestEntrySurvivesRestart — durability is the whole point: a fresh
@@ -48,7 +51,7 @@ func TestDigestEntrySurvivesRestart(t *testing.T) {
if err != nil {
t.Fatalf("open: %v", err)
}
id, _, err := s1.EnqueueDigestEntry(ctx, "break", 2, "перерыв", now, now.Add(24*time.Hour))
id, _, err := s1.EnqueueDigestEntry(ctx, "break", "break-occurrence-1", 2, "перерыв", now, now.Add(24*time.Hour))
if err != nil {
t.Fatalf("enqueue: %v", err)
}
@@ -72,15 +75,15 @@ func TestDigestEntrySurvivesRestart(t *testing.T) {
}
}
// TestDigestEntryDedupesSameRuleAndBody — the same suppressed nudge
// TestDigestEntryDedupesSameCandidateBeforeWording — the same semantic nudge
// repeating across ticks (quiet hours holding for hours) must not pile up
// into several copies of itself; he hears it once.
func TestDigestEntryDedupesSameRuleAndBody(t *testing.T) {
// into several copies even if a concurrent phraser returned another variant.
func TestDigestEntryDedupesSameCandidateBeforeWording(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now()
id1, deduped1, err := s.EnqueueDigestEntry(ctx, "break", 2, "перерыв нужен", now, now.Add(24*time.Hour))
id1, deduped1, err := s.EnqueueDigestEntry(ctx, "break", "break-occurrence-1", 2, "перерыв нужен", now, now.Add(24*time.Hour))
if err != nil {
t.Fatalf("first enqueue: %v", err)
}
@@ -89,12 +92,12 @@ func TestDigestEntryDedupesSameRuleAndBody(t *testing.T) {
}
for i := 0; i < 2; i++ {
id2, deduped2, err := s.EnqueueDigestEntry(ctx, "break", 2, "перерыв нужен", now.Add(time.Minute), now.Add(25*time.Hour))
id2, deduped2, err := s.EnqueueDigestEntry(ctx, "break", "break-occurrence-1", 2, "другой вариант", now.Add(time.Minute), now.Add(25*time.Hour))
if err != nil {
t.Fatalf("repeat enqueue: %v", err)
}
if !deduped2 {
t.Fatal("repeat enqueue of the same rule+body should report deduped")
t.Fatal("repeat enqueue of the same candidate should report deduped")
}
if id2 != id1 {
t.Fatalf("deduped enqueue should return the original id: want %d got %d", id1, id2)
@@ -119,7 +122,7 @@ func TestDigestEntryExpiresRatherThanDeliversLate(t *testing.T) {
created := time.Now()
expiresAt := created.Add(time.Hour)
id, _, err := s.EnqueueDigestEntry(ctx, "water", 1, "стакан воды", created, expiresAt)
id, _, err := s.EnqueueDigestEntry(ctx, "water", "water-occurrence-1", 1, "стакан воды", created, expiresAt)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
@@ -169,11 +172,11 @@ func TestDigestEntryDrainMarksDrainedNotDeleted(t *testing.T) {
ctx := context.Background()
now := time.Now()
id1, _, err := s.EnqueueDigestEntry(ctx, "break", 2, "перерыв", now, now.Add(24*time.Hour))
id1, _, err := s.EnqueueDigestEntry(ctx, "break", "break-occurrence-1", 2, "перерыв", now, now.Add(24*time.Hour))
if err != nil {
t.Fatalf("enqueue 1: %v", err)
}
id2, _, err := s.EnqueueDigestEntry(ctx, "break2", 2, "другое", now, now.Add(24*time.Hour))
id2, _, err := s.EnqueueDigestEntry(ctx, "break2", "break2-occurrence-1", 2, "другое", now, now.Add(24*time.Hour))
if err != nil {
t.Fatalf("enqueue 2: %v", err)
}
@@ -213,7 +216,7 @@ func TestDigestEnqueueDoesNotDedupeAgainstAnExpiredEntry(t *testing.T) {
created := time.Now()
expiresAt := created.Add(time.Hour)
first, deduped, err := s.EnqueueDigestEntry(ctx, "break", 2, "ты долго не отдыхала", created, expiresAt)
first, deduped, err := s.EnqueueDigestEntry(ctx, "break", "break-occurrence-1", 2, "ты долго не отдыхала", created, expiresAt)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
@@ -224,7 +227,7 @@ func TestDigestEnqueueDoesNotDedupeAgainstAnExpiredEntry(t *testing.T) {
// A tick after the expiry, with the sweep not yet run: the same suppressed
// nudge comes round again and must be recorded afresh.
after := expiresAt.Add(time.Minute)
second, deduped, err := s.EnqueueDigestEntry(ctx, "break", 2, "ты долго не отдыхала", after, after.Add(time.Hour))
second, deduped, err := s.EnqueueDigestEntry(ctx, "break", "break-occurrence-1", 2, "ты долго не отдыхала", after, after.Add(time.Hour))
if err != nil {
t.Fatalf("re-enqueue: %v", err)
}
@@ -243,3 +246,48 @@ func TestDigestEnqueueDoesNotDedupeAgainstAnExpiredEntry(t *testing.T) {
t.Fatalf("want the fresh entry %d pending, got %+v", second, entries)
}
}
func TestDigestCandidateMeaningChangeCreatesAnotherEntry(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now()
first, deduped, err := s.EnqueueDigestEntry(ctx, "break", "break-occurrence-1", 2, "перерыв", now, now.Add(time.Hour))
if err != nil || deduped {
t.Fatalf("first enqueue: id=%d deduped=%v err=%v", first, deduped, err)
}
second, deduped, err := s.EnqueueDigestEntry(ctx, "break", "break-occurrence-2", 2, "перерыв", now.Add(time.Minute), now.Add(time.Hour))
if err != nil || deduped {
t.Fatalf("changed candidate enqueue: id=%d deduped=%v err=%v", second, deduped, err)
}
if second == first {
t.Fatalf("changed candidate reused id %d", first)
}
entries, err := s.PendingDigestEntries(ctx, now.Add(time.Minute))
if err != nil || len(entries) != 2 {
t.Fatalf("two distinct occurrences should remain pending: entries=%+v err=%v", entries, err)
}
}
func TestLiveDigestEntryStopsMatchingAfterDrain(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now()
fingerprint := "break-occurrence-1"
id, _, err := s.EnqueueDigestEntry(ctx, "break", fingerprint, 2, "перерыв", now, now.Add(time.Hour))
if err != nil {
t.Fatal(err)
}
if _, found, err := s.LiveDigestEntry(ctx, "break", fingerprint, now); err != nil || !found {
t.Fatalf("live lookup before drain: found=%v err=%v", found, err)
}
if err := s.DrainDigestEntries(ctx, []int64{id}, now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if _, found, err := s.LiveDigestEntry(ctx, "break", fingerprint, now.Add(time.Minute)); err != nil || found {
t.Fatalf("drained occurrence still matched: found=%v err=%v", found, err)
}
if _, deduped, err := s.EnqueueDigestEntry(ctx, "break", fingerprint, 2, "новый перерыв", now.Add(2*time.Minute), now.Add(2*time.Hour)); err != nil || deduped {
t.Fatalf("resolved occurrence should be enqueueable again: deduped=%v err=%v", deduped, err)
}
}