76a6a007ef
The most load-bearing decision in the project was stated four incompatible ways: the docs said Qwen3-1.7B, deploy/mavend.json said Qwen3.5-2B, the repo's models/llm/ held an LFM2.5-1.2B gguf, and five code comments still said LFM. Answering "which model is deployed" meant re-deriving it from scratch every time. Two facts the review missed, found while resolving it: - /mnt/hdd1/llms is bind-mounted over /opt/maven/models/llm, which shadows the repo's models/llm/. The LFM2.5 gguf sitting there was never loaded by anything, so it was not evidence of the deployed model at all. - That library holds Qwen3.5-0.8B, -2B and -4B, and no Qwen3-1.7B. The config pointed at a file that does exist; the docs' Qwen3-1.7B was the stale claim, the reverse of the assumed direction. Qwen3-1.7B is the CPT target, and that training is still in flight (Vikunja #122), so no such gguf exists yet. phraser.model_path moves to Qwen3.5-0.8B (Q4_K_M) — the smallest checkpoint on disk, chosen for latency, and relevant to whether the LLM router is affordable on this box. Docs and comments now say the same thing in one voice: 0.8B resident now, CPT'd Qwen3-1.7B as the target, and the bind-mount shadowing written down so the next reader does not mistake models/llm/ for ground truth. Comments name the model, never a filename, so a swap stays a one-line config change. n_gpu_layers: 99 is correct and stays — compose passes /dev/dri and the render gid for Vulkan offload to the Vega iGPU. CLAUDE.md's "CPU-only" was the stale half of that contradiction and is corrected. phraser.go also dropped a wrong "sub-1b, prompted not trained" size claim: the target is trained end-to-end (RU CPT + joint persona/router SFT). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
139 lines
4.9 KiB
Go
139 lines
4.9 KiB
Go
package loop
|
||
|
||
import (
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/store"
|
||
)
|
||
|
||
// Gate — universal, applied by the loop, never per-rule.
|
||
//
|
||
// quiet-hours, presence, cooldown, snooze, calendar-busy all live in ONE
|
||
// fires(). Cross-cutting restraint in one place or it drifts.
|
||
//
|
||
// The gate does NOT itself decide "should this rule run" — the Rule.Predicate
|
||
// does. The gate answers "is it ALLOWED to fire right NOW" given the snapshot.
|
||
// Suppression-context ("don't nag mid-meeting") moves INTO the gate as an env
|
||
// predicate, not the LLM's job — same boundary as "rules decide, llm phrases."
|
||
//
|
||
// Gate is pure. no I/O. reads State + Rule only.
|
||
func Gate(s State, r Rule) bool {
|
||
now := s.Now
|
||
|
||
// snooze — per-rule "leave me alone until X". overrides everything below.
|
||
if until, ok := s.SnoozeUntil[r.Name]; ok && now.Before(until) {
|
||
return false
|
||
}
|
||
|
||
// cooldown — most recent same-rule nudge + base/feedback-tuned duration.
|
||
// Persisted as `facts (source=feedback)`; Gatherer rolls it into CooldownUntil.
|
||
if until, ok := s.CooldownUntil[r.Name]; ok && now.Before(until) {
|
||
return false
|
||
}
|
||
|
||
// quiet hours — care nudges (sev1–2) shut up. ops (sev ≥3) still surface
|
||
// (a failed backup at 2am genuinely matters and maven routes to telegram).
|
||
if s.QuietHours && r.Severity.IsCare() {
|
||
return false
|
||
}
|
||
|
||
// calendar busy — "don't nag mid-meeting" lifted INTO the gate as an env
|
||
// predicate; not the LLM's call.
|
||
if s.CalendarBusy && r.Severity.IsCare() {
|
||
return false
|
||
}
|
||
|
||
// presence — sev1–2 DROP on away (missed water nudge is noise).
|
||
// sev ≥3 HOLDS — see the delivery channel-routing table in the spec.
|
||
// (the loop doesn't pick the channel; it just decides whether to emit.)
|
||
if s.Presence == store.Away && r.Severity.IsCare() {
|
||
return false
|
||
}
|
||
|
||
// no-data inertness — since(key)==null → don't fire. shut up when uncertain.
|
||
// The predicate MAY have encoded this itself; the gate enforces it for any
|
||
// rule that declared InertWhenNoData keys.
|
||
for _, k := range r.InertWhenNoData {
|
||
if _, ok := s.Fact(k); !ok {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// Candidate — a rule that Wants (predicate true) AND Is Allowed (gate true).
|
||
// The loop picks one per tick (max severity).
|
||
type Candidate struct {
|
||
Rule Rule
|
||
Severity Severity
|
||
State State // snapshot at evaluation time — for the phraser's context
|
||
}
|
||
|
||
// Tick — PURE. Evaluates the configured rules against the snapshot, returns
|
||
// AT MOST one proactive candidate (max severity, with a deterministic tie-break).
|
||
// Returns nil when nothing fires ("shuts up" is the default outcome of a tick).
|
||
//
|
||
// Phrasing + sending happen OUT of the loop — the daemon hands Candidate to
|
||
// the phraser (the resident model, Qwen3-1.7B) and delivery module. The loop
|
||
// just decides.
|
||
//
|
||
// Reminders are NOT handled here — they're a separate, gate-bypassing class.
|
||
// See DueReminders (gathered separately) and RemindDecisions (the loop output
|
||
// flag for the daemon).
|
||
func Tick(s State, rules []Rule) *Candidate {
|
||
var fire *Candidate
|
||
for _, r := range rules {
|
||
if !r.Predicate(s) {
|
||
continue // rule doesn't want to fire — skip gate entirely (cheap path)
|
||
}
|
||
if !Gate(s, r) {
|
||
continue // wanted but suppressed this tick
|
||
}
|
||
c := Candidate{Rule: r, Severity: r.Severity, State: s}
|
||
if fire == nil {
|
||
fire = &c
|
||
continue
|
||
}
|
||
// max severity wins; tie-break: severity desc, then name asc for determinism.
|
||
if c.Severity > fire.Severity ||
|
||
(c.Severity == fire.Severity && c.Rule.Name < fire.Rule.Name) {
|
||
fire = &c
|
||
}
|
||
}
|
||
return fire
|
||
}
|
||
|
||
// ReminderDecision — a due reminder the daemon should deliver now.
|
||
// NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours;
|
||
// that's the point). Snooze still applies — represented by a separate
|
||
// snooze-until the gatherer consults; for the scaffold, fired-reminders move
|
||
// straight to MarkReminder(fired).
|
||
type ReminderDecision struct {
|
||
Reminder store.Reminder
|
||
State State
|
||
}
|
||
|
||
// RemindDecisions — returns all due reminders (without gating their delivery
|
||
// by restraint). Pure: accepts an already-filtered (due) list. The Gatherer
|
||
// produces that list from `fire_ts <= now AND pending`.
|
||
func RemindDecisions(s State, due []store.Reminder) []ReminderDecision {
|
||
out := make([]ReminderDecision, 0, len(due))
|
||
for _, r := range due {
|
||
out = append(out, ReminderDecision{Reminder: r, State: s})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// CooldownFor — helper for the Gatherer: given the active cooldown base
|
||
// (the rule's static Base, OR the feedback tuner's persisted tuning) and the
|
||
// last send ts, compute the wall-clock "cooldown-until" the gate will check.
|
||
// Pure. The auto-tuner writes the base the gatherer reads as a feedback
|
||
// fact; this function just adds it to the last send.
|
||
func CooldownFor(base time.Duration, lastSend time.Time) time.Time {
|
||
if lastSend.IsZero() {
|
||
return time.Time{} // never sent → no cooldown active
|
||
}
|
||
return lastSend.Add(base)
|
||
}
|