package backthink import ( "fmt" "time" ) // Importance is the estimated severity of an event that woke a thinking pass. It // is the primary positive signal in the interrupt decision: a low-importance // heartbeat almost never interrupts, while a critical safety event normally does. type Importance int const ( // ImportanceLow is a routine trigger (e.g. the periodic heartbeat). ImportanceLow Importance = iota // ImportanceNormal is an ordinary event worth reflecting on (device change). ImportanceNormal // ImportanceHigh is a notable event (a reminder the user set firing). ImportanceHigh // ImportanceCritical is a safety-critical event that may override work/busy. ImportanceCritical ) // String returns a stable label for logs and records. func (i Importance) String() string { switch i { case ImportanceLow: return "low" case ImportanceNormal: return "normal" case ImportanceHigh: return "high" case ImportanceCritical: return "critical" default: return fmt.Sprintf("importance(%d)", int(i)) } } // highBoredomThreshold is the boredom (0..1) above which a normal daytime event // is worth interrupting a bored, otherwise idle user. It is a const so no magic // number appears in the decision path. const highBoredomThreshold = 0.7 // UserState describes the user's current condition as observed from device // telemetry (heart rate, sleep) and schedule (work, busy). It is the negative // signal in the interrupt decision: a sleeping, working or busy user is left // alone. Boredom (0..1) is the soft positive dial that widens interruption. type UserState struct { // HeartRateBPM is the current heart rate, 0 when unknown. HeartRateBPM int // Sleeping reports whether the user is currently asleep from sleep tracking. Sleeping bool // Working reports whether the user is in a work/scheduled block. Working bool // Busy reports whether the user is marked busy (calendar / do-not-disturb). Busy bool // Boredom ranges 0..1. Higher boredom means the user is idle and more open // to a gentle interruption. Boredom float64 } // String returns a concise human-readable summary for logs/records. func (s UserState) String() string { return fmt.Sprintf("bpm=%d sleeping=%v working=%v busy=%v boredom=%.2f", s.HeartRateBPM, s.Sleeping, s.Working, s.Busy, s.Boredom) } // DecisionInput is the set of signals Decide weighs: the event's importance, the // current wall-clock time, the user's current state and their boredom. type DecisionInput struct { // Trigger is a human-readable source label, recorded for traceability. Trigger string // Import is the event's estimated importance. Import Importance // At is the current wall-clock time, in the local timezone used for the // do-not-disturb window. At time.Time // UserState carries the user's physiological/schedule condition. UserState UserState } // Decision is the outcome of Decide. type Decision struct { // ShouldPush reports whether the user should be interrupted. ShouldPush bool // Reason is a short human-readable justification, surfaced on the panel. Reason string } // Decide applies the interrupt rule from design/12 §2 (and design/11 §6) with // "不打扰 (do not interrupt) as the first principle" as its bias. It is a pure // method on Backthink only so it can read the configured quiet-hours window; it // does not touch state. // // The evaluation order is deliberately fail-closed (prefer not interrupting): // // 1. A sleeping user is never interrupted. // 2. A working or busy user is interrupted only by a critical event. // 3. Inside the quiet-hours (deep-night) window only high/critical events // interrupt; low/normal events are deferred. // 4. In the daytime, high/critical events always interrupt; a normal event // interrupts only when the user is quite bored; a low event never does. func (b *Backthink) Decide(in DecisionInput) Decision { s := in.UserState if s.Sleeping { return Decision{ShouldPush: false, Reason: "user is asleep; not interrupting"} } if (s.Working || s.Busy) && in.Import < ImportanceCritical { return Decision{ShouldPush: false, Reason: "user is working/busy; not interrupting"} } if b.inQuietHours(in.At) { if in.Import >= ImportanceHigh { return Decision{ShouldPush: true, Reason: "quiet hours but event is important"} } return Decision{ShouldPush: false, Reason: "quiet hours; not interrupting"} } switch in.Import { case ImportanceCritical, ImportanceHigh: return Decision{ShouldPush: true, Reason: "high/critical importance during daytime"} case ImportanceNormal: if s.Boredom >= highBoredomThreshold { return Decision{ShouldPush: true, Reason: "normal importance and user is bored"} } return Decision{ShouldPush: false, Reason: "normal importance; not worth interrupting"} default: return Decision{ShouldPush: false, Reason: "low importance; not interrupting"} } } // disturbAllowed is the hard "do not disturb" safety gate for an autonomous push // (design/12 §2 first principle). Unlike Decide it deliberately ignores trigger // importance — which design/20 P2 replaces with her own judgment — and only blocks // when it is genuinely inappropriate to interrupt: the user is asleep, working or // busy, or it is inside the quiet-hours window. The frequency gate (PushGate) // applies separately inside the pusher. func (b *Backthink) disturbAllowed(at time.Time, s UserState) bool { if s.Sleeping || s.Working || s.Busy { return false } return !b.inQuietHours(at) } // interruptReason is the short human-readable note recorded/logged when no push // goes out (or as the pre-push intent). It keeps the panel self-explanatory: // either she concluded no push, or the do-not-disturb gate refused a push she // wanted. func (b *Backthink) interruptReason(push bool, at time.Time, s UserState) string { switch { case !push: return "conclusion requested no push" case !b.disturbAllowed(at, s): return "proactive push blocked by do-not-disturb" default: return "proactive push approved" } } // inQuietHours reports whether at falls inside the configured do-not-disturb // window. The window may wrap midnight (e.g. 23:00-07:00); two equal non-zero // hours disable it. func (b *Backthink) inQuietHours(at time.Time) bool { h := at.Hour() start, end := b.cfg.QuietStartHour, b.cfg.QuietEndHour if start <= end { return h >= start && h < end } return h >= start || h < end }