113 lines
3.8 KiB
Go
113 lines
3.8 KiB
Go
// 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) } |