Give a suppressed rule a durable semantic identity (V-687)
The digest needs to know whether a candidate is already pending before it pays the phraser, and prose is not identity: phrasing varies, and State.Now advancing does not turn the same unmet condition into a new event. A rule eligible for the digest declares DigestIdentity beside its predicate. DigestCandidateFingerprint frames the rule name and severity around it so two rules cannot alias on a shared fact. BreakRule anchors on the last completed break, not on desk_active, which the poller refreshes without the unmet need changing. A rule that declares no identity does not enter the digest, since a generic state hash would either change every tick or ignore an input the rule reads.
This commit is contained in:
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user