// Package store — presence. // // Presence is a pure function over recent facts, computed each tick; decaying // confidence over multiple weak signals, never one authoritative source; // hysteresis (a Schmitt trigger) to stop flapping. // // `presenceScore` and `resolve` are deliberately pure: no I/O, no time.Now. // The loop supplies `now` and the Signal readings (gathered under the state // lock) as inputs — the functions here are the unit-testable core. // // Signals and their hand-tuned weights/τ: // // (desk_active, 0.90, 8 min) input = human at keyboard // (page_heartbeat,0.60, 4 min) a surface you use is open + alive; pings ~30s // (wg_handshake, 0.40, 20 min) device on tunnel; coarse, three-rooms-away // // Combiner is noisy-OR: P = 1 − Π (1 − p_i) with p_i = weight_i · exp(-Δt_i / τ_i). // Diminishing returns on stacking weak signals, never exceeds 1.0. // Signals with no fact for that key drop out of the product (not zero). // // Hysteresis (Schmitt trigger): // // ENTER (away → present): P >= 0.55 // EXIT (present → away): P < 0.30 // cold start: away (fail-closed; same instinct as since(key)==null) // // Boundaries are HAND-tuned, NOT feedback-tuned — keep presence numbers out of // the auto-tuner or a weird week drifts you silently invisible. package store import ( "math" "time" ) // Signal — one presence signal with hand-tuned fresh-weight and decay τ. type Signal struct { Key string Weight float64 TauMin float64 } // PresenceSignals — the three signals. Iterate in stable order. var PresenceSignals = []Signal{ {Key: "desk_active", Weight: 0.90, TauMin: 8.0}, {Key: "page_heartbeat", Weight: 0.60, TauMin: 4.0}, {Key: "wg_handshake", Weight: 0.40, TauMin: 20.0}, } // PresenceEnter — the ENTER threshold of the Schmitt trigger. const PresenceEnter = 0.55 // PresenceExit — the EXIT threshold. const PresenceExit = 0.30 // SignalProbe — the loop's reading of one signal at tick time. All that // presence needs is, per signal key, the timestamp of the latest non-voided // fact for that key (or nil if no fact — it then drops out of the product). // // The loop fills this by calling LatestFact for each Signal.Key under the // state lock; presence itself does no I/O. type SignalProbe struct { Key string LastTs *time.Time // nil → no data; SignalProbe drops out of the product } // PresenceScore — pure. returns the noisy-OR combined score in [0,1). // If every signal has no data the product collapses to 1.0 and score = 0. // (Cold boot: away, by construction.) func PresenceScore(now time.Time, probes []SignalProbe) float64 { var pAway = 1.0 byKey := make(map[string]*time.Time, len(probes)) for i := range probes { byKey[probes[i].Key] = probes[i].LastTs } for _, s := range PresenceSignals { last, ok := byKey[s.Key] if !ok || last == nil { continue // no data → drops out of the product } dtMin := now.Sub(*last).Minutes() if dtMin < 0 { dtMin = 0 // clock skew shouldn't gift us a p > weight } p := s.Weight * math.Exp(-dtMin/s.TauMin) pAway *= (1.0 - p) } return 1.0 - pAway } // Resolve — the Schmitt trigger. Pure. // // last == present: stay present while P >= 0.30; flip to away below. // last == away: stay away while P < 0.55; enter present at 0.55 or above. // // Cold start (no prior bucket) → away, fail-closed. func Resolve(score float64, last Bucket) Bucket { if last == Present { if score < PresenceExit { return Away } return Present } // last == Away or cold-start if score >= PresenceEnter { return Present } return Away } // ResolveCold — convenience for the very first tick after daemon cold-start. // presence_state has no row; we begin as Away, the fail-closed outcome. func ResolveCold(score float64) Bucket { return Resolve(score, Away) }