5c01fe338b
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.
87 lines
2.9 KiB
Go
87 lines
2.9 KiB
Go
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...)
|
|
}
|