diff --git a/internal/loop/digest_identity.go b/internal/loop/digest_identity.go new file mode 100644 index 0000000..5904e17 --- /dev/null +++ b/internal/loop/digest_identity.go @@ -0,0 +1,86 @@ +package loop + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "math" + + "github.com/kami/maven/internal/store" +) + +// DigestIdentity identifies one semantic occurrence of a rule while it is +// waiting behind a restraint gate. It is deliberately produced by the rule, +// beside its predicate: generated prose is presentation, not identity, and +// State.Now advancing does not turn the same unmet condition into a new event. +// +// A nil or empty identity means the rule has not declared a safe durable +// identity and therefore cannot enter the suppressed-nudge digest. Failing +// closed here is cheaper and safer than inventing a generic state hash that +// either changes every tick or silently ignores an input the rule actually +// uses. +type DigestIdentity func(State) []byte + +// DigestCandidateFingerprint returns the durable, opaque key used to decide +// whether a suppressed candidate is already pending. Rule name and severity +// are framed alongside the rule-owned identity so two rules can never alias +// merely because they happen to read the same fact. +func DigestCandidateFingerprint(r Rule, s State) (string, bool) { + if r.DigestIdentity == nil { + return "", false + } + identity := r.DigestIdentity(s) + if len(identity) == 0 { + return "", false + } + + h := sha256.New() + writeDigestFrame(h, []byte("maven-digest-candidate-v1")) + writeDigestFrame(h, []byte(r.Name)) + var severity [8]byte + binary.BigEndian.PutUint64(severity[:], uint64(r.Severity)) + writeDigestFrame(h, severity[:]) + writeDigestFrame(h, identity) + return hex.EncodeToString(h.Sum(nil)), true +} + +type digestWriter interface { + Write([]byte) (int, error) +} + +func writeDigestFrame(w digestWriter, value []byte) { + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(value))) + _, _ = w.Write(size[:]) + _, _ = w.Write(value) +} + +// factDigestIdentity encodes the complete durable identity of one fact row. +// A new fact row means a new observation even when its human-readable value +// happens to be the same; changing any stored claim field also changes the +// identity in synthetic states used by tests and simulations where ID may be +// zero. +func factDigestIdentity(f store.Fact) []byte { + var fixed [32]byte + binary.BigEndian.PutUint64(fixed[0:8], uint64(f.ID)) + binary.BigEndian.PutUint64(fixed[8:16], uint64(f.Ts.UnixNano())) + binary.BigEndian.PutUint64(fixed[16:24], math.Float64bits(f.Confidence)) + if f.VoidsID.Valid { + binary.BigEndian.PutUint64(fixed[24:32], uint64(f.VoidsID.Int64)) + } + + out := make([]byte, 0, len(fixed)+len(f.Key)+len(f.Value)+len(f.Source)+len(f.Kind)+40) + out = append(out, fixed[:]...) + out = appendDigestFrame(out, []byte(f.Kind)) + out = appendDigestFrame(out, []byte(f.Key)) + out = appendDigestFrame(out, []byte(f.Value)) + out = appendDigestFrame(out, []byte(f.Source)) + return out +} + +func appendDigestFrame(dst, value []byte) []byte { + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(value))) + dst = append(dst, size[:]...) + return append(dst, value...) +} diff --git a/internal/loop/digest_identity_test.go b/internal/loop/digest_identity_test.go new file mode 100644 index 0000000..5168933 --- /dev/null +++ b/internal/loop/digest_identity_test.go @@ -0,0 +1,54 @@ +package loop + +import ( + "testing" + "time" + + "github.com/kami/maven/internal/store" +) + +func TestBreakDigestFingerprintTracksOccurrenceNotTickTime(t *testing.T) { + now := refTime() + lastBreak := store.Fact{ + ID: 41, Ts: now.Add(-2 * time.Hour), Kind: store.KindSelf, + Key: "break", Value: "done", Source: "tap:voice", Confidence: 1, + } + state := State{Now: now, Facts: map[string]store.Fact{"break": lastBreak}} + + first, ok := DigestCandidateFingerprint(BreakRule(), state) + if !ok || first == "" { + t.Fatal("break rule did not produce a digest fingerprint") + } + state.Now = now.Add(20 * time.Minute) + second, ok := DigestCandidateFingerprint(BreakRule(), state) + if !ok || second != first { + t.Fatalf("the same break occurrence changed with tick time: %q then %q", first, second) + } + + newBreak := lastBreak + newBreak.ID++ + newBreak.Ts = now.Add(-100 * time.Minute) + state.Facts["break"] = newBreak + third, ok := DigestCandidateFingerprint(BreakRule(), state) + if !ok || third == first { + t.Fatal("a new last-break observation did not change candidate meaning") + } +} + +func TestDigestCandidateFingerprintFailsClosedWithoutRuleIdentity(t *testing.T) { + r := Rule{Name: "future-care-rule", Severity: Sev2, Predicate: func(State) bool { return true }} + if got, ok := DigestCandidateFingerprint(r, State{}); ok || got != "" { + t.Fatalf("undeclared digest identity must fail closed, got %q, ok=%v", got, ok) + } +} + +func TestDefaultDigestEligibleRulesDeclareIdentity(t *testing.T) { + for _, r := range DefaultRules() { + if r.Severity != Sev2 { + continue + } + if r.DigestIdentity == nil { + t.Errorf("digest-eligible default rule %q has no durable identity", r.Name) + } + } +} diff --git a/internal/loop/rules.go b/internal/loop/rules.go index aa6ed21..7420161 100644 --- a/internal/loop/rules.go +++ b/internal/loop/rules.go @@ -23,6 +23,13 @@ type Rule struct { Cooldown // base cooldown + bounded-duration envelope for the auto-tuner Predicate func(State) bool + // DigestIdentity names one semantic occurrence while this rule is held by + // quiet hours, presence, or calendar restraint. The durable digest checks + // its fingerprint before asking the phraser for prose, so it must change + // when the candidate's meaning changes and remain stable while only Now + // advances. Only rules eligible for that digest need to declare it. + DigestIdentity DigestIdentity + // InertWhenNoData — most rules should be silent when their substrate key is // missing (since(key)==null → don't fire). If the predicate already encodes // that check itself, leave this empty. Otherwise set to the key(s) the rule @@ -109,6 +116,17 @@ func BreakRule() Rule { Severity: Sev2, Cooldown: Cooldown{Base: 45 * time.Minute, Min: 20 * time.Minute, Max: 4 * time.Hour}, InertWhenNoData: []string{"desk_active", "break"}, + // The last completed break is the semantic anchor for this stretch. + // desk_active is freshness evidence for the predicate and may be + // refreshed every poll; treating each refresh as a new occurrence would + // defeat durable dedupe even though the unmet need did not change. + DigestIdentity: func(s State) []byte { + lastBreak, ok := s.Fact("break") + if !ok { + return nil + } + return factDigestIdentity(lastBreak) + }, Predicate: func(s State) bool { dDesk, ok1 := s.Since("desk_active") dBreak, ok2 := s.Since("break")