Files
Maven/internal/store/presence.go
T
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:20:16 +04:00

114 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) }