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
+32 -11
View File
@@ -125,12 +125,10 @@ const maxDigestSpokenItems = 3
// enqueueSuppressedDigest scans this tick's trace for care candidates the
// gate blocked for a genuine restraint reason and durably records the
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
// at enqueue time — not re-derived at drain time — the same way queueNudge
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
// makes repeat calls here harmless, but skipping the phrase call entirely
// when a pending entry already exists avoids the LLM round-trip too).
// digest-eligible ones (loop.DigestEligible). Before phrasing, the candidate's
// rule-owned semantic fingerprint is checked against the durable queue. This
// is intentionally not a prose hash or an in-memory cache: phrasing may vary,
// and the first tick after a restart owes the same zero-model-work behavior.
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
if trace == nil {
return
@@ -142,7 +140,24 @@ func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.Tick
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
continue
}
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
rule, ok := t.ruleNamed(tr.RuleName)
if !ok {
log.Printf("tick: digest candidate %s has no configured rule", tr.RuleName)
continue
}
fingerprint, ok := loop.DigestCandidateFingerprint(rule, state)
if !ok {
log.Printf("tick: digest candidate %s has no semantic identity", tr.RuleName)
continue
}
if _, live, err := t.store.LiveDigestEntry(ctx, tr.RuleName, fingerprint, now); err != nil {
// If durable state cannot answer, do not spend model work whose
// result cannot be safely deduplicated or recorded.
log.Printf("tick: check digest candidate %s: %v", tr.RuleName, err)
continue
} else if live {
continue
}
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
pn, err := t.phraser.PhraseNudge(ctx, cand)
if err != nil {
@@ -150,15 +165,21 @@ func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.Tick
continue
}
expires := now.Add(digestExpiry)
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
if _, _, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, fingerprint, int(tr.Severity), pn.Body, now, expires); err != nil {
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
} else if deduped {
// same suppressed nudge already pending — nothing new to say.
continue
}
}
}
func (t *tickLoop) ruleNamed(name string) (loop.Rule, bool) {
for _, rule := range t.rules {
if rule.Name == name {
return rule, true
}
}
return loop.Rule{}, false
}
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {