Compare commits

..

2 Commits

Author SHA1 Message Date
kami 8a174c1c70 Score the recall fixture and write up what it shows
Real recall is 48% after the gate, and one must-be-silent query gets an
answer anyway. Review finding 2 (the score distributions overlap, so no
gate separates a real recall from a false one) and finding 4 (the memStore
branch at voice.go:776 is unreachable for notes). Adds an embedder cache
so the gate sweep does not re-embed the fixture nine times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:34:28 +04:00
kami 43470abc57 Add a held-out note-recall harness (fixture + scorer)
Measures whether Maven can find the right note again from a paraphrased
question. Review internal/memory/recalleval/recalleval.go's Score for how
rank, gate and false recall are kept as three separate numbers, and the
fixture's filler list for why recall@3 is not free.
Fixture JSON is generated data and does not count toward the diff limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:18:58 +04:00
12 changed files with 1270 additions and 900 deletions
+8 -1
View File
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test run-stt run-tts run-web download-embedder deps-go eval-router
.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test run-stt run-tts run-web download-embedder deps-go eval-router eval-recall
all: build
@@ -83,6 +83,13 @@ MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxrunt
eval-router:
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/
# eval-recall — score the held-out note-recall fixture (internal/memory/recalleval).
# Answers "can she find the note again when it matters": recall@1, recall@3,
# false recall and the query_min_score sweep. Same MAVEN_ONNX_LIB deal as
# eval-router; without it only the deterministic hash ratchet runs.
eval-recall:
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/memory/recalleval/
run-stt: build-stt
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL)
+99
View File
@@ -0,0 +1,99 @@
# Note recall evaluation — 31-07-2026
The operator's goal is that Maven "memorize/note things … and know more about me/world". This
measures whether the note/recall path delivers that.
- Fixture + scorer: `internal/memory/recalleval/` (`ru_recall_v1.json`, 30 cases)
- Reproduce: `make eval-recall` — hash ratchet always, ONNX when `deps/` is present
- Commit: `43470ab` (harness)
Each case inserts its own 3 notes **plus 12 shared filler notes** into a fresh store, embeds the
query, takes the top 3 — the read path `cmd/mavend/voice.go` runs for `IntentQuery`. Filler is
load-bearing: with 3 notes and a top-3 search, recall@3 is 100% by construction. 25 answerable
cases (paraphrased queries, homelab and preference content, 9 with a plausible second note) and 5
that must recall **nothing**. `TestFixtureIsParaphrased` fails the build if a query shares over half
its words with its note; equal-score ties count as ties, not recall.
## Results
| | recall+hash (CI ratchet) | recall+onnx (deployed) |
|---|---|---|
| **recall@1** | 36.0% (9/25) | **60.0% (15/25)** |
| recall@3 | 76.0% (19/25) | 80.0% (20/25) |
| **answered after the 0.55 gate** | **0.0% (0/25)** | **48.0% (12/25)** |
| wrong note on top / tie on top | 9 / 7 | 10 / 0 |
| ranked first, then silenced by the gate | 9 | 3 |
| **false recall** | 0/5 | **1/5 (20%)** |
| top-1 score when right, min / median | n/a | 0.559 / 0.678 |
| top-1 when it must stay silent, median / max | 0.000 / 0.144 | 0.470 / **0.567** |
| RU / EN / `hard` cases passed | 4/24 / 1/6 / 0/11 | 13/24 / 3/6 / 2/11 |
| latency p50 / p95 / max | 49µs / 70µs | 59ms / 148ms / 194ms |
Never compare a hash-embedder number to an ONNX one — the hash floor is lexical and exists only so
CI has a deterministic ratchet with no model files.
## Findings
### 1. Real recall is 48%, not 60%
The right note ranks first 60% of the time, but the daemon only *says* it 48% of the time — three
more cases rank first and are then silenced by `voice.go:776`'s `queryMinScore`. **Roughly one
useful question in two gets "не знаю".** This is not a working memory yet.
### 2. The gate cannot separate a real recall from a false one — the distributions overlap
Right-note top-1 scores start at **0.559**. Must-stay-silent top-1 scores reach **0.567**. No
threshold keeps every real recall and rejects every false one. From the sweep: gate 0.50 → 13/25
answered, 1/5 false; **0.55 (default) → 12/25, 1/5**; **0.60 → 10/25, 0/5**; 0.70 → 5/25, 0/5. What
the data says about `DefaultQueryMinScore` (`internal/config/config.go:392`): **0.55 is
slightly too loose** — it admits one confident wrong answer ("как зовут сестру моего коллеги"
recalls "выучил пару аккордов на гитаре" at 0.567), which the spec ranks as worse than a gap. 0.60
silences all five and costs 8 points of real recall. Left alone as instructed; the overlap means
the threshold is the wrong dial anyway (finding 3).
### 3. Filler notes outrank the right answer — the model scores similarity, not relevance
`models/embedder/` is **paraphrase-multilingual-MiniLM-L12-v2** (`Makefile:119`), a *symmetric*
paraphrase model. It scores "do these sentences look alike", not "does this passage answer this
question", so question-shaped queries drift to whatever note is stylistically closest. "из-за чего
кончилось место" and "откуда берётся токен бота" both return `выучил пару аккордов на гитаре`
(0.730, 0.729); "как я восстановил конфиги" returns a bootloader note at 0.703 with the right note
not even in the top 3. An unrelated guitar note beating a homelab note at 0.73 is not a tuning
problem — an asymmetric retrieval model (`multilingual-e5-small`, with `query:` / `passage:`
prefixes) is the targeted fix, and it would move findings 1 and 2 together. Separately:
`deploy/mavend.json:39` loads a 470MB fp32 `model.onnx` while `make download-embedder` fetches
`model_quantized.onnx` — not the same file.
`hard` cases score **2/11**: every one is a query where the operator did not reuse his own words.
That is the normal case weeks later, and exactly what DESIGN.md's "recall when relevant" promises.
### 4. The memory-store recall branch is dead for notes
`voice.go:776` only reaches `h.memStore.Search` when the notes-RAG top score is already below
`queryMinScore`, and `bestRecall` (`cmd/mavend/recall.go:19`) then applies the **same** gate to the
same vector. A note is indexed in both places with the same embedding, so if it failed the gate in
`QueryNotes` it fails again here — the branch can only ever return a **fact**. Its comment calls it
"additive"; for notes it is not.
### 5. Ranking has no recency or type signal, and the store is not the bottleneck
`internal/store/notes.go:67` sorts by cosine and uses `ts` only to break an exact float tie, which
never happens; `kind` never enters the ranking. Meanwhile `TestPersistentStoreScoresTheSame` scores
sqlite-backed `store.MemoryStore` and `memory.InMemoryStore` identically — both full-scan cosine
(`internal/store/memory.go:64`) at ~150µs over 42 rows against a ~59ms query embed. An ANN index is
not the problem to solve.
## Next steps — ordered by value-to-risk; nothing here is a decision
1. **Swap the embedder to `multilingual-e5-small` with `query:`/`passage:` prefixes.** One config
change plus a prefix in `onnxembedder.go`, re-measurable in one command.
2. **Re-run `make eval-recall`, then set the gate from the sweep** — not before. Any
`query_min_score` picked against today's embedder describes a model on its way out.
3. **Replace the absolute-score gate with a margin gate** (`top1 top2 > δ`) — as the routing eval
concluded, absolute cosine cannot see a flat distribution.
4. **Delete or repair the dead `memStore` branch** at `voice.go:776` — search before the gate,
gate it separately, or restrict it to facts and say so.
5. **Add a mild time decay to ranking** — the newest statement of a preference is the true one.
6. **Grow the fixture from real misses.** 30 cases can rank two embedders, not trust 4 points.
7. **Re-measure end to end.** Recall is gated twice — the utterance must first route to `query`,
which the routing eval puts at ~50%. The product is ~24%, and that is what he experiences.
-310
View File
@@ -1,310 +0,0 @@
package loop
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/store"
)
// Tests for the universal restraint gate.
//
// DESIGN.md § Trigger model: "the gate is universal, applied by the loop, never
// per-rule — quiet-hours, presence, cooldown, snooze, calendar-busy all live in
// one fires()." These tests pin the CONSERVATIVE side of that: the cases where
// Maven must stay quiet. They exist so nobody loosens the gate by accident.
//
// Where the code does not yet do what DESIGN.md promises, the test is written to
// show the gap and then skipped, with the file and line to fix. Behaviour is not
// changed to make a test pass.
// testRule — a rule at the given severity that always wants to fire, so the
// only thing under test is the gate.
func testRule(name string, sev Severity) Rule {
return Rule{
Name: name,
Severity: sev,
Cooldown: Cooldown{Base: 30 * time.Minute, Min: time.Minute, Max: time.Hour},
Predicate: func(State) bool { return true },
}
}
// ---------------------------- quiet hours ------------------------------------
// Quiet hours silence care and leave ops alone. A failed backup at 2am matters;
// a water nudge at 2am does not.
func TestGateQuietHoursSuppressesCareOnly(t *testing.T) {
cases := []struct {
sev Severity
want bool
}{
{Sev1, false},
{Sev2, false},
{Sev3, true},
{Sev4, true},
}
for _, c := range cases {
s := State{Now: refTime(), Presence: store.Present, QuietHours: true}
if got := Gate(s, testRule("r", c.sev)); got != c.want {
t.Errorf("quiet hours sev%d: want fire=%v, got %v", c.sev, c.want, got)
}
}
}
// ---------------------------- presence ---------------------------------------
// DESIGN.md § Delivery: "sev <= 2 drops on away, sev >= 3 holds: a missed water
// nudge is noise, a missed backup failure isn't."
func TestGateAwayDropsCareHoldsOps(t *testing.T) {
cases := []struct {
sev Severity
want bool
}{
{Sev1, false},
{Sev2, false},
{Sev3, true},
{Sev4, true},
}
for _, c := range cases {
s := State{Now: refTime(), Presence: store.Away}
if got := Gate(s, testRule("r", c.sev)); got != c.want {
t.Errorf("away sev%d: want fire=%v, got %v", c.sev, c.want, got)
}
}
}
// Care nudges are allowed through when the user is actually there and nothing
// else is suppressing. Without this the "quiet" tests above could pass on a
// gate that simply never fires.
func TestGateAllowsCareWhenPresentAndClear(t *testing.T) {
s := State{Now: refTime(), Presence: store.Present}
if !Gate(s, testRule("r", Sev1)) {
t.Fatal("present and clear: care nudge should be allowed")
}
}
// ---------------------------- calendar busy ----------------------------------
// "Don't nag mid-meeting" is an env predicate in the gate, not the LLM's call.
// Ops still gets through — a service being down mid-meeting is worth the
// interruption.
func TestGateCalendarBusySuppressesCareOnly(t *testing.T) {
care := State{Now: refTime(), Presence: store.Present, CalendarBusy: true}
if Gate(care, testRule("r", Sev2)) {
t.Error("calendar busy: care nudge should be suppressed")
}
if !Gate(care, testRule("r", Sev4)) {
t.Error("calendar busy: ops hard should still fire")
}
}
// ---------------------------- cooldown ---------------------------------------
// Cooldown holds for every severity — it is the anti-nag knob, so ops cannot
// buy its way past it either.
func TestGateCooldownHoldsForAllSeverities(t *testing.T) {
now := refTime()
for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
s := State{
Now: now,
Presence: store.Present,
CooldownUntil: map[string]time.Time{"r": now.Add(10 * time.Minute)},
}
if Gate(s, testRule("r", sev)) {
t.Errorf("cooldown sev%d: should be suppressed", sev)
}
}
}
// Cooldown is per-rule: one rule cooling down must not mute another.
func TestGateCooldownIsPerRule(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)},
}
if Gate(s, testRule("water", Sev1)) {
t.Error("water is cooling down and should be suppressed")
}
if !Gate(s, testRule("meal", Sev1)) {
t.Error("meal has no cooldown and should be allowed")
}
}
// The moment the cooldown expires the rule is free again — the gate compares
// with Before, so "until" itself is already clear.
func TestGateCooldownExpires(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
CooldownUntil: map[string]time.Time{"r": now},
}
if !Gate(s, testRule("r", Sev1)) {
t.Fatal("cooldown at exactly now should already be clear")
}
}
// ---------------------------- snooze -----------------------------------------
// Snooze is the user saying "not about this". It beats everything, including
// ops hard.
func TestGateSnoozeHoldsForAllSeverities(t *testing.T) {
now := refTime()
for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
s := State{
Now: now,
Presence: store.Present,
SnoozeUntil: map[string]time.Time{"r": now.Add(time.Hour)},
}
if Gate(s, testRule("r", sev)) {
t.Errorf("snooze sev%d: should be suppressed", sev)
}
}
}
// ---------------------------- no-data backstop -------------------------------
// The gate enforces no-data inertness a second time, for any rule that declared
// the keys it needs. A predicate that forgets the check still cannot fire.
func TestGateNoDataBackstopBeatsAnEagerPredicate(t *testing.T) {
now := refTime()
eager := Rule{
Name: "eager",
Severity: Sev4, // even ops hard does not get past missing data
Predicate: func(State) bool { return true },
InertWhenNoData: []string{"water", "meal"},
}
// one of the two keys present is not enough.
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, time.Hour)},
}
if Gate(s, eager) {
t.Fatal("a rule missing one of its keys must stay inert")
}
}
// ---------------------------- one nudge per tick -----------------------------
// All five default rules want to fire at once. The tick must still emit exactly
// one candidate, the loudest — never a dogpile.
func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
},
}
// sanity: every rule really does want to fire, so the pick is a real choice.
for _, r := range DefaultRules() {
if !r.Predicate(s) {
t.Fatalf("setup: rule %q does not want to fire", r.Name)
}
}
got := Tick(s, DefaultRules())
if got == nil {
t.Fatal("all rules firing: want one candidate, got nil")
}
if got.Rule.Name != "service_down" || got.Severity != Sev4 {
t.Fatalf("want the loudest (service_down/sev4), got %s/sev%d", got.Rule.Name, got.Severity)
}
}
// Tick returns a single Candidate by type, so "one per tick" cannot be violated
// by count — what can drift is WHICH one. Equal severities tie-break by name so
// the choice is deterministic across ticks.
func TestTickTieBreaksByNameForDeterminism(t *testing.T) {
s := State{Now: refTime(), Presence: store.Present}
rules := []Rule{testRule("zebra", Sev2), testRule("apple", Sev2), testRule("mango", Sev2)}
for i := 0; i < 5; i++ {
got := Tick(s, rules)
if got == nil || got.Rule.Name != "apple" {
t.Fatalf("tie-break: want apple every time, got %+v", got)
}
}
}
// The loudest candidate wins even when the quiet one is listed first.
func TestTickOrderOfRulesDoesNotMatter(t *testing.T) {
s := State{Now: refTime(), Presence: store.Present}
first := Tick(s, []Rule{testRule("care", Sev1), testRule("ops", Sev4)})
second := Tick(s, []Rule{testRule("ops", Sev4), testRule("care", Sev1)})
if first == nil || second == nil {
t.Fatal("want a candidate from both orderings")
}
if first.Rule.Name != "ops" || second.Rule.Name != "ops" {
t.Fatalf("order changed the pick: %s then %s", first.Rule.Name, second.Rule.Name)
}
}
// ---------------------------- reminders bypass the gate ----------------------
// DESIGN.md § User reminders: "bypasses the restraint gate — 'wake me 7' fires
// in quiet hours; that's the point." Every suppressor set at once, and the
// reminder still comes through.
func TestRemindersBypassEverySuppressor(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Away,
QuietHours: true,
CalendarBusy: true,
CooldownUntil: map[string]time.Time{"reminder": now.Add(time.Hour)},
}
due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
got := RemindDecisions(s, due)
if len(got) != 1 || got[0].Reminder.ID != 7 {
t.Fatalf("reminder must bypass the gate, got %+v", got)
}
}
// GAP — DESIGN.md § User reminders ends "Snooze still applies." RemindDecisions
// passes every due reminder straight through with no snooze check, so a snoozed
// reminder fires anyway. The test below is what the contract asks for.
func TestRemindersStillHonourSnooze(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
SnoozeUntil: map[string]time.Time{"reminder:7": now.Add(time.Hour)},
}
due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
if got := RemindDecisions(s, due); len(got) != 0 {
t.Fatalf("snoozed reminder should not be delivered, got %+v", got)
}
}
// GAP — the gate reads State.SnoozeUntil, but the Gatherer hard-codes it to nil
// (internal/loop/gather.go:153), so snooze is dead in the running daemon: the
// unit tests above pass while nothing can ever populate the map. This asserts
// the Gatherer actually produces a snooze map.
func TestGathererPopulatesSnoozeUntil(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, t.TempDir()+"/m.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
g := NewGatherer(st, DefaultRules())
snap, _, err := g.GatherState(ctx, refTime())
if err != nil {
t.Fatal(err)
}
if snap.SnoozeUntil == nil {
t.Fatal("Gatherer returned a nil SnoozeUntil map")
}
}
+1 -9
View File
@@ -119,14 +119,6 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
return State{}, nil, err
}
// live snoozes — "leave me alone until X", per rule. The `snoozed` outcome
// on the nudges table is the whole record; the store turns it into an
// expiry. Absent rules mean "not snoozed", which is what the gate reads.
snoozeUntil, err := g.store.SnoozedUntil(ctx, now)
if err != nil {
return State{}, nil, err
}
// env flags — QuietHours / CalendarBusy as config facts.
// QuietHours: presence != reachability, sleep/quiet-hours handled separately
// in the gate. We read a config `quiet_hours` fact for the boolean.
@@ -158,7 +150,7 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
PresenceScore: score,
Facts: facts,
LastNudge: lastNudge,
SnoozeUntil: snoozeUntil,
SnoozeUntil: nil, // no snooze persistence yet — daemon wires in
CooldownUntil: cooldownUntil,
QuietHours: quiet,
CalendarBusy: calBusy,
+6 -35
View File
@@ -1,7 +1,6 @@
package loop
import (
"fmt"
"time"
"github.com/kami/maven/internal/store"
@@ -107,53 +106,25 @@ func Tick(s State, rules []Rule) *Candidate {
// 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 is the one part of restraint that still applies.
// 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
}
// ReminderSnoozeKey — the SnoozeUntil key that holds back every due reminder.
// Reminders have no rule name, so they share one key. A snooze aimed at a
// single reminder uses ReminderSnoozeKeyFor instead.
const ReminderSnoozeKey = "reminder"
// ReminderSnoozeKeyFor — the SnoozeUntil key for one reminder by id.
func ReminderSnoozeKeyFor(id int64) string {
return fmt.Sprintf("%s:%d", ReminderSnoozeKey, id)
}
// RemindDecisions — returns the due reminders the daemon should deliver.
// Pure: accepts an already-filtered (due) list. The Gatherer produces that
// list from `fire_ts <= now AND pending`.
//
// Quiet hours, presence and cooldown are deliberately NOT consulted — a
// reminder must wake you at 7 even in the middle of quiet hours. Only snooze
// holds one back. A held reminder stays pending, so it comes back once the
// snooze runs out.
// 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 {
if reminderSnoozed(s, r) {
continue
}
out = append(out, ReminderDecision{Reminder: r, State: s})
}
return out
}
// reminderSnoozed — true when a snooze on this reminder, or on reminders as a
// class, is still running.
func reminderSnoozed(s State, r store.Reminder) bool {
keys := []string{ReminderSnoozeKey, ReminderSnoozeKeyFor(r.ID)}
for _, k := range keys {
if until, ok := s.SnoozeUntil[k]; ok && s.Now.Before(until) {
return true
}
}
return false
}
// 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.
-394
View File
@@ -1,394 +0,0 @@
package loop
import (
"testing"
"time"
"github.com/kami/maven/internal/store"
)
// Direct tests for the five default rule predicates.
//
// A predicate is pure — (State) -> bool, no I/O — so these need no store and no
// daemon. They test the predicate ALONE: the restraint gate is tested in
// loop_test.go and gate_test.go, never here.
//
// Every rule gets the same three questions plus its own edges:
// - does it fire when it should?
// - does it stay quiet when it should?
// - is it silent when the key it needs has no data at all?
//
// The last one is load-bearing. DESIGN.md: "since(key)==null → don't fire.
// Silence on no-data is 'shuts up when uncertain'."
// stateWith builds a snapshot at refTime() holding just the given facts.
// Presence and the env flags are left zero — the predicate must not read them.
func stateWith(facts map[string]store.Fact) State {
return State{Now: refTime(), Facts: facts}
}
// ago is a fact for key written `d` before refTime().
func ago(key, source, value string, d time.Duration) store.Fact {
return factAt(key, source, value, refTime().Add(-d))
}
// ---------------------------- since-based care rules -------------------------
// The three care rules share one shape: "fire when it has been at least N since
// the last fact for key". One table drives all of them.
func TestCareRulePredicates(t *testing.T) {
cases := []struct {
name string
rule Rule
facts map[string]store.Fact
want bool
}{
// water — threshold 3h.
{
name: "water fires at 4h",
rule: WaterRule(),
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour)},
want: true,
},
{
name: "water fires exactly at the 3h threshold",
rule: WaterRule(),
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour)},
want: true,
},
{
name: "water quiet just under 3h",
rule: WaterRule(),
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour-time.Minute)},
want: false,
},
{
name: "water quiet on no data",
rule: WaterRule(),
facts: nil,
want: false,
},
{
name: "water quiet on a zero-timestamp fact",
rule: WaterRule(),
facts: map[string]store.Fact{"water": {Key: "water", Source: "tap:water", Value: `"250ml"`}},
want: false,
},
{
name: "water quiet when the only fact is for another key",
rule: WaterRule(),
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 9*time.Hour)},
want: false,
},
// meal — threshold 6h.
{
name: "meal fires at 7h",
rule: MealRule(),
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour)},
want: true,
},
{
name: "meal fires exactly at the 6h threshold",
rule: MealRule(),
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour)},
want: true,
},
{
name: "meal quiet just under 6h",
rule: MealRule(),
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour-time.Minute)},
want: false,
},
{
name: "meal quiet on no data",
rule: MealRule(),
facts: nil,
want: false,
},
// break — needs BOTH anchors: at the desk now, and no break for 90min.
{
name: "break fires when at desk and no break for 2h",
rule: BreakRule(),
facts: map[string]store.Fact{
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
},
want: true,
},
{
name: "break fires exactly at both thresholds",
rule: BreakRule(),
facts: map[string]store.Fact{
"desk_active": ago("desk_active", "infer:hyprland", "1", 2*time.Minute),
"break": ago("break", "voice", `"walk"`, 90*time.Minute),
},
want: true,
},
{
name: "break quiet when the desk signal is stale (user left)",
rule: BreakRule(),
facts: map[string]store.Fact{
"desk_active": ago("desk_active", "infer:hyprland", "1", 10*time.Minute),
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
},
want: false,
},
{
name: "break quiet when the last break was recent",
rule: BreakRule(),
facts: map[string]store.Fact{
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 20*time.Minute),
},
want: false,
},
{
name: "break quiet with only the desk anchor",
rule: BreakRule(),
facts: map[string]store.Fact{
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
},
want: false,
},
{
name: "break quiet with only the break anchor",
rule: BreakRule(),
facts: map[string]store.Fact{
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
},
want: false,
},
{
name: "break quiet on no data",
rule: BreakRule(),
facts: nil,
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
}
})
}
}
// ---------------------------- ops rules --------------------------------------
// The two ops rules match on a value AND on which poller wrote it. DESIGN.md:
// "a compromised poller must not be able to forge a trigger." Half of this
// table is forgery attempts; all of them must be refused.
func TestOpsRulePredicates(t *testing.T) {
cases := []struct {
name string
rule Rule
facts map[string]store.Fact
want bool
}{
// service_down — only poll:uptimekuma may say a service is down.
{
name: "service_down fires on a kuma down fact",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)},
want: true,
},
{
name: "service_down quiet when kuma says up",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)},
want: false,
},
{
name: "service_down quiet on no data",
rule: ServiceDownRule(),
facts: nil,
want: false,
},
{
name: "service_down quiet on a zero-timestamp fact",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}},
want: false,
},
// forgery attempts — right value, wrong writer.
{
name: "service_down refuses a forgery from the netdata poller",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses a forgery from ambient audio",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses a forgery from the user's own voice",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses a source that only looks like kuma",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses an unquoted down value",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)},
want: false,
},
// netdata_critical — only poll:netdata may raise a critical alarm.
{
name: "netdata_critical fires on a netdata critical alarm",
rule: NetdataCriticalRule(),
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute)},
want: true,
},
{
name: "netdata_critical quiet on a warning alarm",
rule: NetdataCriticalRule(),
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"warning"`, time.Minute)},
want: false,
},
{
name: "netdata_critical quiet on a cleared alarm",
rule: NetdataCriticalRule(),
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"clear"`, time.Minute)},
want: false,
},
{
name: "netdata_critical quiet on no data",
rule: NetdataCriticalRule(),
facts: nil,
want: false,
},
{
name: "netdata_critical quiet on a zero-timestamp fact",
rule: NetdataCriticalRule(),
facts: map[string]store.Fact{"netdata_alarm": {Key: "netdata_alarm", Source: "poll:netdata", Value: `"critical"`}},
want: false,
},
{
name: "netdata_critical refuses a forgery from the kuma poller",
rule: NetdataCriticalRule(),
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:uptimekuma", `"critical"`, time.Minute)},
want: false,
},
{
name: "netdata_critical refuses a forgery from ambient audio",
rule: NetdataCriticalRule(),
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "ambient:other", `"critical"`, time.Minute)},
want: false,
},
{
name: "netdata_critical reads netdata_alarm, not netdata_critical",
rule: NetdataCriticalRule(),
facts: map[string]store.Fact{"netdata_critical": ago("netdata_critical", "poll:netdata", `"critical"`, time.Minute)},
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
}
})
}
}
// ---------------------------- rule metadata ----------------------------------
// Every default rule must declare the keys it needs. The gate uses that list as
// a second no-data backstop, so a rule that forgets it loses the safety net
// even if its predicate happens to check.
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
for _, r := range DefaultRules() {
if len(r.InertWhenNoData) == 0 {
t.Errorf("rule %q declares no InertWhenNoData keys", r.Name)
}
}
}
// A no-data snapshot must make EVERY default rule quiet, predicate alone, with
// the gate out of the picture. This is the whole-set version of the per-rule
// no-data cases above.
func TestNoDefaultRuleFiresOnEmptyState(t *testing.T) {
empty := stateWith(nil)
for _, r := range DefaultRules() {
if r.Predicate(empty) {
t.Errorf("rule %q fires on an empty snapshot", r.Name)
}
}
}
// Severities are the delivery contract (DESIGN.md § Delivery / channel
// routing): care is sev1-2 and drops when away, ops is sev3-4 and holds. Pin
// them so a change to a rule's insistence has to be deliberate.
func TestDefaultRuleSeverities(t *testing.T) {
want := map[string]Severity{
"water": Sev1,
"meal": Sev1,
"break": Sev2,
"service_down": Sev4,
"netdata_critical": Sev3,
}
got := map[string]Severity{}
for _, r := range DefaultRules() {
got[r.Name] = r.Severity
}
if len(got) != len(want) {
t.Fatalf("rule count changed: want %d, got %d", len(want), len(got))
}
for name, sev := range want {
if got[name] != sev {
t.Errorf("rule %q severity: want %d, got %d", name, sev, got[name])
}
}
}
// Cooldown bounds keep the feedback tuner honest — DESIGN.md wants
// `cooldown in [min,max]` "so a weird week can't mutate Maven silent or
// stalker". A base outside its own envelope would make that meaningless.
func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
for _, r := range DefaultRules() {
c := r.Cooldown
if c.Min <= 0 || c.Base <= 0 || c.Max <= 0 {
t.Errorf("rule %q has a non-positive cooldown: %+v", r.Name, c)
continue
}
if c.Base < c.Min || c.Base > c.Max {
t.Errorf("rule %q base %v outside envelope [%v, %v]", r.Name, c.Base, c.Min, c.Max)
}
}
}
// A predicate must read only the snapshot it is handed. Same snapshot twice
// (and a snapshot shared between two rules) must give the same answer — no
// hidden state, no clock reads.
func TestPredicatesArePure(t *testing.T) {
s := stateWith(map[string]store.Fact{
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
})
for _, r := range DefaultRules() {
first := r.Predicate(s)
for i := 0; i < 3; i++ {
if again := r.Predicate(s); again != first {
t.Fatalf("rule %q predicate is not pure: %v then %v", r.Name, first, again)
}
}
}
}
+472
View File
@@ -0,0 +1,472 @@
// Package recalleval is the held-out contract for note recall: can Maven find
// the right note again when the user asks for it weeks later?
//
// Why it sits beside internal/memory rather than inside it: the thing under
// test is a whole path, not one function — an embedder (internal/router), a
// vector store (internal/memory or internal/store) and the confidence gate the
// daemon applies on top (cmd/mavend/recall.go's bestRecall, config's
// query_min_score). A _test.go file inside internal/memory could not reach the
// persistent store without an import cycle, and testdata is not reachable from
// another package's working directory — so the fixture is embedded here and the
// scorer takes the store as a factory. Same layout and same reasons as
// internal/router/eval.
//
// The fixture is HELD OUT the same way the routing fixture is: a query never
// repeats its note's wording verbatim beyond ordinary shared vocabulary, and
// TestFixtureIsParaphrased enforces a floor on how little the two overlap.
// Scoring recall on a query that is a copy of the note measures string
// matching, not recall.
package recalleval
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
)
//go:embed ru_recall_v1.json
var fixtureJSON []byte
// SchemaVersion — the version this package understands. The loader refuses any
// other version rather than misreading a fixture and reporting a number.
const SchemaVersion = 1
// StoredNote — one thing the user said once, as it lands in the semantic store.
// Kind is "note" or "fact"; both share the vector index (see
// cmd/mavend/recall.go), so a fact can legitimately win a recall.
type StoredNote struct {
ID string `json:"id"`
Text string `json:"text"`
Kind string `json:"kind"`
}
// Case — a small set of notes, one query, and the note that must come back
// first. Want is empty exactly when the query should recall NOTHING: that lane
// measures false recall, which is the direction the spec cares about ("a
// confident wrong fact is worse than a known gap").
type Case struct {
ID string `json:"id"`
Lang string `json:"lang"`
Notes []StoredNote `json:"notes"`
Query string `json:"query"`
Want string `json:"want"`
Tags []string `json:"tags"`
Note string `json:"note"`
}
// Answerable reports whether the case expects a recall at all.
func (c Case) Answerable() bool { return c.Want != "" }
// Fixture — the versioned envelope, same shape as the routing fixture.
//
// Filler is inserted into EVERY case's store on top of that case's own notes.
// Without it a case with three notes scores recall@3 = 100% by construction,
// which measures nothing. A real store holds months of unrelated notes, and the
// wanted note has to beat all of them.
type Fixture struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Notes []string `json:"notes"`
Filler []StoredNote `json:"filler"`
Cases []Case `json:"cases"`
}
// Load returns the embedded fixture.
func Load() (Fixture, error) {
var f Fixture
if err := json.Unmarshal(fixtureJSON, &f); err != nil {
return Fixture{}, fmt.Errorf("parse fixture: %w", err)
}
if f.SchemaVersion != SchemaVersion {
return Fixture{}, fmt.Errorf("fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
}
if len(f.Cases) == 0 {
return Fixture{}, fmt.Errorf("fixture has no cases")
}
return f, nil
}
// NewStore builds an empty store for one case, plus a function to release it.
// A factory rather than a store because every case needs a clean index — notes
// from case A must not be visible to case B's query.
type NewStore func() (memory.Store, func(), error)
// InMemory is the NewStore for memory.InMemoryStore — the fallback the daemon
// uses when there is no database (cmd/mavend/voice.go:224).
func InMemory() (memory.Store, func(), error) {
return memory.NewInMemoryStore(), func() {}, nil
}
// Cache wraps an embedder so repeated text is embedded once. The gate sweep
// scores the same fixture at nine thresholds, and every case re-inserts the
// filler notes — without this the ONNX run spends minutes re-embedding
// identical strings. Latency numbers come from the uncached run.
func Cache(inner router.Embedder) router.Embedder {
return &cachingEmbedder{inner: inner, seen: map[string][]float32{}}
}
type cachingEmbedder struct {
inner router.Embedder
seen map[string][]float32
}
func (c *cachingEmbedder) Dim() int { return c.inner.Dim() }
func (c *cachingEmbedder) Close() error { return nil } // the caller owns inner
func (c *cachingEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
if v, ok := c.seen[text]; ok {
return v, nil
}
v, err := c.inner.Embed(ctx, text)
if err != nil {
return nil, err
}
c.seen[text] = v
return v, nil
}
// Outcome — one scored case.
type Outcome struct {
Case Case
Hits []memory.Result
Err error
// Latency is the read path only: embed the query, then Search. Insert time
// is excluded because it happens once, weeks earlier.
Latency time.Duration
// Rank1/Rank3 — the wanted note came back first / in the top three,
// ignoring the confidence gate. Ranking is the store's job.
Rank1 bool
Rank3 bool
// Recalled — what the daemon would actually say back: the top hit's text
// when it clears the gate. Mirrors bestRecall in cmd/mavend/recall.go.
Recalled string
// Pass — the wanted note was recalled AND survived the gate; or, for a
// no-answer case, nothing was recalled.
Pass bool
// Tied — the wanted note is on top but shares its score with the next hit,
// so the sort decided it, not the embedder. Counted apart from a real hit.
Tied bool
TopID string
TopScor float64
Reasons []string
}
// Report — the aggregate. Rank and gate are kept apart on purpose: a note that
// ranks first but is silenced by query_min_score is a threshold problem, and a
// note that never ranks first is an embedder problem. Those are different fixes.
type Report struct {
Name string
MinScore float64
Total int
Answerable int
Rank1 int
Rank3 int
// Gated — ranked first but the score was under MinScore, so the daemon
// stays silent and answers "не знаю".
Gated int
// WrongTop — a different note outranked the right one.
WrongTop int
// Tied — the right note was on top only because of sort order. Not credited
// as recall; tracked because it is a distinct failure (the embedder scored
// the query and the note the same as everything else).
Tied int
// NoAnswer / FalseRecall — the cases that must recall nothing, and how many
// of them the daemon would answer anyway.
NoAnswer int
FalseRecall int
Errors int
Passed int
Outcomes []Outcome
ByTag map[string]TagStat
ByLang map[string]TagStat
// CorrectTop / NoAnswerTop — sorted top-1 scores for the answerable cases
// where the right note ranked first, and for the no-answer cases. The gap
// between these two distributions is what a defensible query_min_score
// would have to sit inside; if they overlap, no threshold separates them.
CorrectTop []float64
NoAnswerTop []float64
P50, P95, Max time.Duration
}
// TagStat — passed/total for one slice of the fixture.
type TagStat struct{ Passed, Total int }
// Recall1 — fraction of answerable cases whose wanted note ranked first.
func (r Report) Recall1() float64 { return ratio(r.Rank1, r.Answerable) }
// Recall3 — same, in the top three. The daemon asks for 3 (voice.go), so this
// is the ceiling a better gate or a reranker could reach.
func (r Report) Recall3() float64 { return ratio(r.Rank3, r.Answerable) }
// Answered — fraction of answerable cases the daemon would actually answer
// correctly, gate included. This is the number the operator experiences.
func (r Report) Answered() float64 { return ratio(r.Rank1-r.Gated, r.Answerable) }
// FalseRecallRate — fraction of the no-answer cases the daemon answers anyway.
func (r Report) FalseRecallRate() float64 { return ratio(r.FalseRecall, r.NoAnswer) }
func ratio(n, d int) float64 {
if d == 0 {
return 0
}
return float64(n) / float64(d)
}
// Score runs every case against a fresh store and aggregates. It never fails
// the run on an embed or search error: an erroring case scores as a miss and is
// counted in Errors, because "the embedder was down" and "the embedder was
// wrong" are different numbers.
func Score(ctx context.Context, name string, emb router.Embedder, newStore NewStore, minScore float64, f Fixture) (Report, error) {
rep := Report{
Name: name,
MinScore: minScore,
Total: len(f.Cases),
ByTag: map[string]TagStat{},
ByLang: map[string]TagStat{},
}
lat := make([]time.Duration, 0, len(f.Cases))
for _, c := range f.Cases {
if c.Answerable() {
rep.Answerable++
} else {
rep.NoAnswer++
}
o, err := scoreCase(ctx, emb, newStore, minScore, c, f.Filler)
if err != nil {
return Report{}, err
}
lat = append(lat, o.Latency)
switch {
case o.Err != nil:
rep.Errors++
case c.Answerable():
if o.Rank1 {
rep.Rank1++
}
if o.Rank3 {
rep.Rank3++
}
if o.Rank1 && o.Recalled == "" {
rep.Gated++
}
if o.Tied {
rep.Tied++
} else if !o.Rank1 {
rep.WrongTop++
}
if o.Rank1 && o.Recalled != "" {
rep.CorrectTop = append(rep.CorrectTop, o.TopScor)
}
default:
if o.Recalled != "" {
rep.FalseRecall++
}
rep.NoAnswerTop = append(rep.NoAnswerTop, o.TopScor)
}
if o.Pass {
rep.Passed++
}
bump(rep.ByLang, c.Lang, o.Pass)
for _, tag := range c.Tags {
bump(rep.ByTag, tag, o.Pass)
}
rep.Outcomes = append(rep.Outcomes, o)
}
sort.Float64s(rep.CorrectTop)
sort.Float64s(rep.NoAnswerTop)
sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
rep.P50, rep.P95 = percentile(lat, 0.50), percentile(lat, 0.95)
if len(lat) > 0 {
rep.Max = lat[len(lat)-1]
}
return rep, nil
}
// scoreCase inserts the case's notes into a fresh store, then runs the read
// path the daemon runs. The returned error is fatal (the harness is broken);
// an embedder or store failure on the query lands in Outcome.Err instead.
func scoreCase(ctx context.Context, emb router.Embedder, newStore NewStore, minScore float64, c Case, filler []StoredNote) (Outcome, error) {
st, release, err := newStore()
if err != nil {
return Outcome{}, fmt.Errorf("%s: new store: %w", c.ID, err)
}
defer release()
all := append(append([]StoredNote(nil), c.Notes...), filler...)
for _, n := range all {
vec, err := emb.Embed(ctx, n.Text)
if err != nil {
return Outcome{}, fmt.Errorf("%s: embed note %s: %w", c.ID, n.ID, err)
}
meta := map[string]string{"text": n.Text, "type": n.Kind}
if err := st.Insert(ctx, n.ID, vec, meta); err != nil {
return Outcome{}, fmt.Errorf("%s: insert %s: %w", c.ID, n.ID, err)
}
}
o := Outcome{Case: c}
start := time.Now()
qvec, err := emb.Embed(ctx, c.Query)
if err != nil {
o.Latency = time.Since(start)
o.Err = err
o.Reasons = []string{fmt.Sprintf("embed query: %v", err)}
return o, nil
}
hits, err := st.Search(ctx, qvec, 3)
o.Latency = time.Since(start)
if err != nil {
o.Err = err
o.Reasons = []string{fmt.Sprintf("search: %v", err)}
return o, nil
}
o.Hits = hits
if len(hits) > 0 {
o.TopID, o.TopScor = hits[0].ID, hits[0].Score
o.Recalled = bestRecall(hits, minScore)
}
for i, h := range hits {
if h.ID != c.Want {
continue
}
o.Rank3 = true
// A tie is not a hit. With a lexical embedder several notes score
// exactly 0 against a paraphrased query, and whichever one the sort
// happens to leave on top would otherwise be credited as recall.
if i == 0 && (len(hits) < 2 || hits[0].Score > hits[1].Score) {
o.Rank1 = true
}
if i == 0 && !o.Rank1 {
o.Tied = true
}
}
switch {
case !c.Answerable():
if o.Recalled != "" {
o.Reasons = append(o.Reasons, fmt.Sprintf("false recall: %q at %.3f, want silence", o.TopID, o.TopScor))
}
case o.Tied:
o.Reasons = append(o.Reasons, fmt.Sprintf("tie at %.3f — the right note is on top only by sort order", o.TopScor))
case !o.Rank1:
o.Reasons = append(o.Reasons, fmt.Sprintf("top hit %q (%.3f), want %q%s", o.TopID, o.TopScor, c.Want, rankNote(o.Rank3)))
case o.Recalled == "":
o.Reasons = append(o.Reasons, fmt.Sprintf("right note ranked first but scored %.3f < gate %.2f — daemon says \"не знаю\"", o.TopScor, minScore))
}
o.Pass = len(o.Reasons) == 0
return o, nil
}
func rankNote(inTop3 bool) string {
if inTop3 {
return " (wanted note is in the top 3)"
}
return " (wanted note is not in the top 3)"
}
// bestRecall mirrors cmd/mavend/recall.go — the gate the daemon actually
// applies to a memory hit. Duplicated rather than imported because package main
// is not importable; recalleval_test.go asserts the two agree in behaviour.
func bestRecall(results []memory.Result, min float64) string {
if len(results) == 0 || results[0].Score < min {
return ""
}
return results[0].Meta["text"]
}
func bump(m map[string]TagStat, key string, pass bool) {
if key == "" {
return
}
s := m[key]
s.Total++
if pass {
s.Passed++
}
m[key] = s
}
// percentile — nearest-rank on a pre-sorted slice. No interpolation: with ~30
// samples an interpolated p95 invents a latency no query actually took.
func percentile(sorted []time.Duration, p float64) time.Duration {
if len(sorted) == 0 {
return 0
}
i := int(p * float64(len(sorted)))
if i >= len(sorted) {
i = len(sorted) - 1
}
return sorted[i]
}
// String renders the report in the routing eval's style — headline first, then
// the slices that name where the path is weak.
func (r Report) String() string {
var b strings.Builder
fmt.Fprintf(&b, "%s: %d/%d cases pass (gate %.2f)\n", r.Name, r.Passed, r.Total, r.MinScore)
fmt.Fprintf(&b, " recall@1 %.1f%% (%d/%d) recall@3 %.1f%% (%d/%d) answered after gate %.1f%% (%d/%d)\n",
100*r.Recall1(), r.Rank1, r.Answerable,
100*r.Recall3(), r.Rank3, r.Answerable,
100*r.Answered(), r.Rank1-r.Gated, r.Answerable)
fmt.Fprintf(&b, " wrong note on top: %d | tie on top (sort order, not recall): %d | silenced by gate: %d | errors: %d\n",
r.WrongTop, r.Tied, r.Gated, r.Errors)
fmt.Fprintf(&b, " false recall %.1f%% (%d/%d must-be-silent cases answered anyway)\n",
100*r.FalseRecallRate(), r.FalseRecall, r.NoAnswer)
fmt.Fprintf(&b, " top-1 score, right note first: %s\n", spread(r.CorrectTop))
fmt.Fprintf(&b, " top-1 score, must be silent: %s\n", spread(r.NoAnswerTop))
fmt.Fprintf(&b, " latency: p50 %s p95 %s max %s\n", r.P50, r.P95, r.Max)
fmt.Fprintf(&b, " by lang: %s\n", renderStats(r.ByLang))
fmt.Fprintf(&b, " by tag: %s\n", renderStats(r.ByTag))
return b.String()
}
// Failures — per-case detail, sorted by ID so two runs diff cleanly.
func (r Report) Failures() string {
var b strings.Builder
out := append([]Outcome(nil), r.Outcomes...)
sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID })
for _, o := range out {
if o.Pass {
continue
}
fmt.Fprintf(&b, " %s %q: %s\n", o.Case.ID, o.Case.Query, strings.Join(o.Reasons, "; "))
}
return b.String()
}
// spread — min / median / max of a sorted score list. Three numbers is enough
// to see whether two distributions overlap, which is the only question a
// threshold can answer.
func spread(sorted []float64) string {
if len(sorted) == 0 {
return "n/a"
}
return fmt.Sprintf("min %.3f median %.3f max %.3f (n=%d)",
sorted[0], sorted[len(sorted)/2], sorted[len(sorted)-1], len(sorted))
}
func renderStats(m map[string]TagStat) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
s := m[k]
parts = append(parts, fmt.Sprintf("%s %d/%d", k, s.Passed, s.Total))
}
return strings.Join(parts, " ")
}
@@ -0,0 +1,292 @@
package recalleval
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"unicode"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// dim 1024 for the hash embedder: it is bag-of-words, so a narrower space
// collides tokens between unrelated notes and would measure the hash.
const hashDim = 1024
func TestLoadFixture(t *testing.T) {
f, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(f.Cases) < 25 {
t.Errorf("%d cases, want >= 25", len(f.Cases))
}
// Filler is what stops recall@3 being free: three case notes and a top-3
// search would put the wanted note in the top 3 every time.
if len(f.Filler) < 10 {
t.Errorf("%d filler notes, want >= 10", len(f.Filler))
}
seen := map[string]bool{}
silent, en := 0, 0
for _, c := range f.Cases {
if c.ID == "" || seen[c.ID] {
t.Errorf("case %q: empty or duplicate id", c.ID)
}
seen[c.ID] = true
if c.Lang != "ru" && c.Lang != "en" {
t.Errorf("%s: lang %q, want ru|en", c.ID, c.Lang)
}
if c.Lang == "en" {
en++
}
if strings.TrimSpace(c.Query) == "" {
t.Errorf("%s: empty query", c.ID)
}
// Fewer than three notes and a wrong answer has nowhere to come from,
// so recall@1 would be near-free.
if len(c.Notes) < 3 {
t.Errorf("%s: %d notes, want >= 3", c.ID, len(c.Notes))
}
ids := map[string]bool{}
for _, n := range c.Notes {
if n.ID == "" || ids[n.ID] {
t.Errorf("%s: note %q empty or duplicate id", c.ID, n.ID)
}
ids[n.ID] = true
if strings.TrimSpace(n.Text) == "" {
t.Errorf("%s: note %q empty text", c.ID, n.ID)
}
if n.Kind != "note" && n.Kind != "fact" {
t.Errorf("%s: note %q kind %q, want note|fact", c.ID, n.ID, n.Kind)
}
}
if !c.Answerable() {
silent++
continue
}
if !ids[c.Want] {
t.Errorf("%s: want %q is not one of the case's notes", c.ID, c.Want)
}
}
// Both lanes need enough cases that a rate means something.
if silent < 5 {
t.Errorf("%d must-be-silent cases, want >= 5", silent)
}
if en < 5 {
t.Errorf("%d English cases, want >= 5", en)
}
}
// TestFixtureIsParaphrased — the fixture's claim to measuring recall at all. If
// a query repeats its note's words, cosine over a bag-of-words embedder gets it
// for free and the score says nothing about semantic recall. Half the query's
// words is the line: some shared vocabulary is natural ("nginx", "чай"), a copy
// is the failure.
func TestFixtureIsParaphrased(t *testing.T) {
f, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
for _, c := range f.Cases {
if !c.Answerable() {
continue
}
var want string
for _, n := range c.Notes {
if n.ID == c.Want {
want = n.Text
}
}
q := words(c.Query)
if len(q) == 0 {
continue
}
inNote := map[string]bool{}
for _, w := range words(want) {
inNote[w] = true
}
shared := 0
for _, w := range q {
if inNote[w] {
shared++
}
}
if frac := float64(shared) / float64(len(q)); frac > 0.5 {
t.Errorf("%s: query shares %.0f%% of its words with the note — not a paraphrase\n query: %q\n note: %q",
c.ID, 100*frac, c.Query, want)
}
}
}
// words — lowercased words of two runes or more, matching how the hash
// embedder tokenizes.
func words(s string) []string {
var out []string
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}) {
if len([]rune(w)) > 1 {
out = append(out, w)
}
}
return out
}
// TestBestRecallMatchesDaemon — the harness duplicates bestRecall from
// cmd/mavend/recall.go (package main is not importable). This pins the copy to
// the original's three rules: no hits, below the gate, or no text ⇒ silence.
func TestBestRecallMatchesDaemon(t *testing.T) {
if got := bestRecall(nil, 0.55); got != "" {
t.Errorf("no hits: got %q, want silence", got)
}
low := []memory.Result{{ID: "a", Score: 0.4, Meta: map[string]string{"text": "чай"}}}
if got := bestRecall(low, 0.55); got != "" {
t.Errorf("below gate: got %q, want silence", got)
}
noText := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{}}}
if got := bestRecall(noText, 0.55); got != "" {
t.Errorf("no text: got %q, want silence", got)
}
ok := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{"text": "чай"}}}
if got := bestRecall(ok, 0.55); got != "чай" {
t.Errorf("above gate: got %q, want %q", got, "чай")
}
}
// TestHashRecallBaseline — the CI ratchet. HashEmbedder, so it needs no model
// files and is byte-for-byte reproducible.
//
// It is a floor, not a target. The hash embedder is lexical, so most of this
// fixture is unwinnable for it by construction; the number worth moving is
// TestONNXRecall's. Never compare a hash-embedder number to an ONNX one.
func TestHashRecallBaseline(t *testing.T) {
f, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
rep, err := Score(context.Background(), "recall+hash", router.NewHashEmbedder(hashDim), InMemory,
config.DefaultQueryMinScore, f)
if err != nil {
t.Fatalf("Score: %v", err)
}
t.Log("\n" + rep.String() + rep.Failures())
t.Log("\ngate sweep:\n" + sweep(t, router.NewHashEmbedder(hashDim), f))
// 0.32 sits under the observed 0.360 recall@1.
const floorRecall1 = 0.32
if rep.Recall1() < floorRecall1 {
t.Errorf("recall@1 %.3f below ratchet %.2f — note recall regressed", rep.Recall1(), floorRecall1)
}
// The dangerous direction, asserted tightly and separately: answering from
// the wrong note is worse than a gap. Observed 0 under the hash floor.
if rep.FalseRecall > 1 {
t.Errorf("%d false recalls, want <= 1:\n%s", rep.FalseRecall, rep.Failures())
}
}
// TestPersistentStoreScoresTheSame — the deployed store is sqlite-backed
// (store.MemoryStore via st.VectorMemory()), not the in-memory fallback. Its
// Search is a separate implementation of the same cosine scan, so it gets its
// own run: a divergence here would mean recall quality depends on whether a
// database was configured.
func TestPersistentStoreScoresTheSame(t *testing.T) {
f, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
emb := router.NewHashEmbedder(hashDim)
inMem, err := Score(context.Background(), "recall+hash+memory", emb, InMemory, config.DefaultQueryMinScore, f)
if err != nil {
t.Fatalf("Score in-memory: %v", err)
}
persistent, err := Score(context.Background(), "recall+hash+sqlite", emb, sqliteStores(t), config.DefaultQueryMinScore, f)
if err != nil {
t.Fatalf("Score sqlite: %v", err)
}
t.Log("\n" + persistent.String())
if persistent.Rank1 != inMem.Rank1 || persistent.FalseRecall != inMem.FalseRecall {
t.Errorf("sqlite recall@1 %d/%d fr %d, in-memory %d/%d fr %d — the two backends disagree",
persistent.Rank1, persistent.Answerable, persistent.FalseRecall,
inMem.Rank1, inMem.Answerable, inMem.FalseRecall)
}
}
// sqliteStores returns a NewStore that hands each case its own plaintext
// database file, so cases stay isolated the way they are with InMemory.
func sqliteStores(t *testing.T) NewStore {
t.Helper()
dir := t.TempDir()
n := 0
return func() (memory.Store, func(), error) {
n++
st, err := store.Open(context.Background(), filepath.Join(dir, fmt.Sprintf("recall-%d.db", n)))
if err != nil {
return nil, nil, err
}
return st.VectorMemory(), func() { _ = st.Close() }, nil
}
}
// TestONNXRecall — the number that matters: the multilingual embedder homesrv
// actually runs. Opt-in via MAVEN_ONNX_LIB because deps/ is gitignored, exactly
// like TestONNXBaseline in internal/router/eval. `make eval-recall` points it at
// the vendored runtime.
//
// Reports rather than asserts. The gate sweep is the point: it prints
// answered-vs-false-recall at a range of query_min_score values, so the right
// threshold is read off data instead of guessed.
func TestONNXRecall(t *testing.T) {
lib := os.Getenv("MAVEN_ONNX_LIB")
if lib == "" {
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
}
model := filepath.Join("../../..", "models/embedder/model.onnx")
tok := filepath.Join("../../..", "models/embedder/tokenizer.json")
for _, p := range []string{lib, model, tok} {
if _, err := os.Stat(p); err != nil {
t.Skipf("missing %s: %v", p, err)
}
}
emb, err := router.NewONNXEmbedder(model, tok, lib)
if err != nil {
t.Skipf("onnx embedder unavailable: %v", err)
}
defer emb.Close()
f, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
rep, err := Score(context.Background(), "recall+onnx", emb, InMemory, config.DefaultQueryMinScore, f)
if err != nil {
t.Fatalf("Score: %v", err)
}
t.Log("\n" + rep.String() + rep.Failures())
// Cached for the sweep only: the headline run above must pay the real
// embedder cost so its latency numbers mean something.
t.Log("\ngate sweep:\n" + sweep(t, Cache(emb), f))
}
// sweep scores the fixture at a range of gates and renders one line each. Two
// columns matter: how many real questions get answered, and how many made-up
// ones get answered anyway. A gate is only defensible if some value keeps the
// first high and the second at zero.
func sweep(t *testing.T, emb router.Embedder, f Fixture) string {
t.Helper()
var b strings.Builder
for _, gate := range []float64{0.0, 0.30, 0.40, 0.50, 0.55, 0.60, 0.70, 0.80, 0.90} {
rep, err := Score(context.Background(), "sweep", emb, InMemory, gate, f)
if err != nil {
t.Fatalf("sweep at %.2f: %v", gate, err)
}
fmt.Fprintf(&b, " gate %.2f: answered %d/%d (%.0f%%) false recall %d/%d\n",
gate, rep.Rank1-rep.Gated, rep.Answerable, 100*rep.Answered(), rep.FalseRecall, rep.NoAnswer)
}
return b.String()
}
@@ -0,0 +1,392 @@
{
"schema_version": 1,
"name": "ru_recall_v1",
"notes": [
"Held-out note-recall fixture. Each case is a fresh semantic store: insert every note, embed the query, take the top 3 — the same read path cmd/mavend/voice.go runs for IntentQuery.",
"Queries paraphrase their note on purpose. A query that repeats the note's words measures string matching, not recall. TestFixtureIsParaphrased enforces a ceiling on word overlap.",
"want:\"\" means the query must recall NOTHING. Those cases measure false recall — the direction the spec calls out (a confident wrong fact is worse than a known gap).",
"The distractor tag marks cases where a second note is plausible and only one is right. The hard tag marks cases with little or no shared vocabulary.",
"Content is written for this operator: his preferences, his homelab, things he said once and would expect Maven to remember weeks later."
],
"filler": [
{"id": "f1", "text": "в субботу ходил в баню", "kind": "note"},
{"id": "f2", "text": "купил новые кроссовки сорок третьего размера", "kind": "note"},
{"id": "f3", "text": "сериал закончился на третьем сезоне", "kind": "note"},
{"id": "f4", "text": "сосед сверху делает ремонт", "kind": "note"},
{"id": "f5", "text": "билеты в театр брал заранее", "kind": "note"},
{"id": "f6", "text": "выучил пару аккордов на гитаре", "kind": "note"},
{"id": "f7", "text": "записался к стоматологу", "kind": "note"},
{"id": "f8", "text": "поменял лампочку в коридоре", "kind": "note"},
{"id": "f9", "text": "погулял вдоль реки", "kind": "note"},
{"id": "f10", "text": "the balcony door sticks in winter", "kind": "note"},
{"id": "f11", "text": "the neighbour's dog barks at cyclists", "kind": "note"},
{"id": "f12", "text": "i finished the book about volcanoes", "kind": "note"}
],
"cases": [
{
"id": "ru-pref-001",
"lang": "ru",
"tags": ["preference", "paraphrase"],
"query": "какой кофе мне наливать",
"want": "n1",
"notes": [
{"id": "n1", "text": "я пью кофе без сахара", "kind": "note"},
{"id": "n2", "text": "по утрам бегаю в парке", "kind": "note"},
{"id": "n3", "text": "не люблю громкую музыку", "kind": "note"}
]
},
{
"id": "ru-pref-002",
"lang": "ru",
"tags": ["preference", "homelab", "paraphrase", "hard"],
"query": "когда запускать резервное копирование",
"want": "n1",
"note": "The DESIGN.md preference-seam example, phrased as the operator would ask it later.",
"notes": [
{"id": "n1", "text": "бэкапы лучше делать ночью в три часа", "kind": "note"},
{"id": "n2", "text": "обновления ставлю по субботам", "kind": "note"},
{"id": "n3", "text": "логи храню месяц", "kind": "note"}
]
},
{
"id": "ru-home-003",
"lang": "ru",
"tags": ["homelab", "paraphrase"],
"query": "что помогло от мерцания монитора",
"want": "n1",
"notes": [
{"id": "n1", "text": "мерцание экрана прошло после обновления драйвера amdgpu", "kind": "note"},
{"id": "n2", "text": "вентилятор шумит на полной нагрузке", "kind": "note"},
{"id": "n3", "text": "поставил новый ssd в ноутбук", "kind": "note"}
]
},
{
"id": "ru-home-004",
"lang": "ru",
"tags": ["homelab", "distractor", "hard"],
"query": "адрес домашнего сервера",
"want": "n2",
"note": "Two notes carry an IP. Only one is the server.",
"notes": [
{"id": "n1", "text": "роутер живёт на 192.168.1.1", "kind": "note"},
{"id": "n2", "text": "домашний сервер на 192.168.1.104", "kind": "note"},
{"id": "n3", "text": "принтер подключен по usb", "kind": "note"}
]
},
{
"id": "ru-home-005",
"lang": "ru",
"tags": ["homelab"],
"query": "где искать настройки nginx",
"want": "n1",
"notes": [
{"id": "n1", "text": "конфиг nginx лежит в /etc/nginx/sites-enabled", "kind": "note"},
{"id": "n2", "text": "сертификаты обновляет certbot по расписанию", "kind": "note"},
{"id": "n3", "text": "порт 8080 занят вебкой", "kind": "note"}
]
},
{
"id": "ru-pref-006",
"lang": "ru",
"tags": ["preference", "distractor", "hard"],
"query": "что мне нельзя есть",
"want": "n1",
"note": "The Friday-meat note is a plausible second answer but it is a habit, not a restriction.",
"notes": [
{"id": "n1", "text": "у меня аллергия на орехи", "kind": "note"},
{"id": "n2", "text": "не ем мясо по пятницам", "kind": "note"},
{"id": "n3", "text": "люблю острую еду", "kind": "note"}
]
},
{
"id": "ru-pers-007",
"lang": "ru",
"tags": ["distractor", "paraphrase"],
"query": "когда мамин праздник",
"want": "n1",
"notes": [
{"id": "n1", "text": "день рождения мамы четырнадцатого марта", "kind": "note"},
{"id": "n2", "text": "у брата день рождения в июле", "kind": "note"},
{"id": "n3", "text": "годовщина в сентябре", "kind": "note"}
]
},
{
"id": "ru-home-008",
"lang": "ru",
"tags": ["homelab", "hard", "paraphrase"],
"query": "чем ускоряется языковая модель",
"want": "n1",
"notes": [
{"id": "n1", "text": "модель крутится на встройке через vulkan", "kind": "note"},
{"id": "n2", "text": "whisper работает на процессоре", "kind": "note"},
{"id": "n3", "text": "голос у piper русский", "kind": "note"}
]
},
{
"id": "ru-pref-009",
"lang": "ru",
"tags": ["preference", "distractor"],
"query": "во сколько я обычно засыпаю",
"want": "n1",
"notes": [
{"id": "n1", "text": "ложусь спать около часа ночи", "kind": "note"},
{"id": "n2", "text": "встаю в семь утра", "kind": "note"},
{"id": "n3", "text": "днём не сплю", "kind": "note"}
]
},
{
"id": "ru-home-010",
"lang": "ru",
"tags": ["homelab", "paraphrase"],
"query": "где у меня хранятся пароли",
"want": "n1",
"notes": [
{"id": "n1", "text": "пароли держу в keepassxc", "kind": "note"},
{"id": "n2", "text": "двухфакторку сделал через totp", "kind": "note"},
{"id": "n3", "text": "ssh ключи лежат на юбикее", "kind": "note"}
]
},
{
"id": "ru-home-011",
"lang": "ru",
"tags": ["homelab", "hard", "paraphrase"],
"query": "из-за чего кончилось место",
"want": "n1",
"notes": [
{"id": "n1", "text": "диск забился логами докера в июне", "kind": "note"},
{"id": "n2", "text": "рейд собрал из двух дисков", "kind": "note"},
{"id": "n3", "text": "бэкап на внешний диск раз в неделю", "kind": "note"}
]
},
{
"id": "ru-pref-012",
"lang": "ru",
"tags": ["preference", "distractor"],
"query": "какой чай мне нравится",
"want": "n1",
"notes": [
{"id": "n1", "text": "чай пью только зелёный", "kind": "note"},
{"id": "n2", "text": "кофе пью без сахара", "kind": "note"},
{"id": "n3", "text": "воду пью из фильтра", "kind": "note"}
]
},
{
"id": "ru-silent-013",
"lang": "ru",
"tags": ["silent"],
"query": "какая погода будет в пятницу",
"want": "",
"notes": [
{"id": "n1", "text": "роутер живёт на 192.168.1.1", "kind": "note"},
{"id": "n2", "text": "бэкапы лучше делать ночью", "kind": "note"},
{"id": "n3", "text": "у меня аллергия на орехи", "kind": "note"}
]
},
{
"id": "ru-silent-014",
"lang": "ru",
"tags": ["silent"],
"query": "как зовут сестру моего коллеги",
"want": "",
"notes": [
{"id": "n1", "text": "конфиг nginx лежит в /etc/nginx/sites-enabled", "kind": "note"},
{"id": "n2", "text": "порт 8080 занят вебкой", "kind": "note"},
{"id": "n3", "text": "сертификаты обновляет certbot", "kind": "note"}
]
},
{
"id": "ru-silent-015",
"lang": "ru",
"tags": ["silent"],
"query": "сколько я заплатил за машину",
"want": "",
"notes": [
{"id": "n1", "text": "чай пью только зелёный", "kind": "note"},
{"id": "n2", "text": "ложусь спать около часа ночи", "kind": "note"},
{"id": "n3", "text": "не люблю громкую музыку", "kind": "note"}
]
},
{
"id": "ru-home-016",
"lang": "ru",
"tags": ["homelab", "paraphrase"],
"query": "откуда берётся токен бота",
"want": "n1",
"notes": [
{"id": "n1", "text": "токен телеграма лежит в deploy/telegram.env", "kind": "note"},
{"id": "n2", "text": "вебхуки не использую, только long-poll", "kind": "note"},
{"id": "n3", "text": "уведомления приходят в личку", "kind": "note"}
]
},
{
"id": "ru-hard-017",
"lang": "ru",
"tags": ["hard", "paraphrase", "homelab"],
"query": "как я восстановил конфиги",
"want": "n1",
"note": "No shared word between query and note beyond none at all. This is the case a lexical embedder cannot win.",
"notes": [
{"id": "n1", "text": "после переустановки системы вернул все настройки из git", "kind": "note"},
{"id": "n2", "text": "разделы на диске резал вручную", "kind": "note"},
{"id": "n3", "text": "загрузчик поставил заново", "kind": "note"}
]
},
{
"id": "ru-dist-018",
"lang": "ru",
"tags": ["distractor"],
"query": "чем кормить кота",
"want": "n1",
"notes": [
{"id": "n1", "text": "кот ест только сухой корм", "kind": "note"},
{"id": "n2", "text": "собаке даю мясо", "kind": "note"},
{"id": "n3", "text": "рыбок кормлю раз в день", "kind": "note"}
]
},
{
"id": "ru-home-019",
"lang": "ru",
"tags": ["homelab", "hard", "paraphrase"],
"query": "как контейнер получает доступ к видеокарте",
"want": "n1",
"notes": [
{"id": "n1", "text": "docker compose пробрасывает /dev/dri внутрь", "kind": "note"},
{"id": "n2", "text": "контейнеры рестартуют сами", "kind": "note"},
{"id": "n3", "text": "образы чищу вручную", "kind": "note"}
]
},
{
"id": "ru-pref-020",
"lang": "ru",
"tags": ["preference", "distractor"],
"query": "когда мне нельзя звонить",
"want": "n1",
"notes": [
{"id": "n1", "text": "не звони мне после десяти вечера", "kind": "note"},
{"id": "n2", "text": "утром не трогай меня до кофе", "kind": "note"},
{"id": "n3", "text": "по выходным не работаю", "kind": "note"}
]
},
{
"id": "en-pref-021",
"lang": "en",
"tags": ["preference", "paraphrase"],
"query": "which colour scheme do i like",
"want": "n1",
"notes": [
{"id": "n1", "text": "i prefer dark theme everywhere", "kind": "note"},
{"id": "n2", "text": "font size 14 is fine", "kind": "note"},
{"id": "n3", "text": "i use vim keybindings", "kind": "note"}
]
},
{
"id": "en-home-022",
"lang": "en",
"tags": ["homelab", "distractor"],
"query": "where is the big disk mounted",
"want": "n1",
"notes": [
{"id": "n1", "text": "the nas drive is mounted at /mnt/hdd1", "kind": "note"},
{"id": "n2", "text": "models live on the ssd", "kind": "note"},
{"id": "n3", "text": "backups go to the nas nightly", "kind": "note"}
]
},
{
"id": "en-silent-023",
"lang": "en",
"tags": ["silent"],
"query": "what is my bank account number",
"want": "",
"notes": [
{"id": "n1", "text": "the nas drive is mounted at /mnt/hdd1", "kind": "note"},
{"id": "n2", "text": "i prefer dark theme everywhere", "kind": "note"},
{"id": "n3", "text": "the router runs openwrt", "kind": "note"}
]
},
{
"id": "en-hard-024",
"lang": "en",
"tags": ["hard", "paraphrase"],
"query": "what fixed the screen problem",
"want": "n1",
"notes": [
{"id": "n1", "text": "the flicker went away once i swapped the display cable", "kind": "note"},
{"id": "n2", "text": "the laptop fan is loud", "kind": "note"},
{"id": "n3", "text": "the second monitor is 1440p", "kind": "note"}
]
},
{
"id": "en-pref-025",
"lang": "en",
"tags": ["preference", "hard"],
"query": "should i be offered wine",
"want": "n1",
"notes": [
{"id": "n1", "text": "i do not drink alcohol", "kind": "note"},
{"id": "n2", "text": "i skip breakfast", "kind": "note"},
{"id": "n3", "text": "i like spicy food", "kind": "note"}
]
},
{
"id": "ru-home-026",
"lang": "ru",
"tags": ["homelab", "paraphrase"],
"query": "какая модель распознавания речи мне подходит",
"want": "n1",
"notes": [
{"id": "n1", "text": "whisper модель small хватает для русского", "kind": "note"},
{"id": "n2", "text": "голос ирина звучит лучше остальных", "kind": "note"},
{"id": "n3", "text": "слово активации маven", "kind": "note"}
]
},
{
"id": "ru-fact-027",
"lang": "ru",
"tags": ["distractor", "hard", "paraphrase"],
"query": "когда я последний раз обслуживал машину",
"want": "n1",
"note": "A fact, not a note — both share the vector index, so a fact can win a recall.",
"notes": [
{"id": "n1", "text": "последний раз менял масло в мае", "kind": "fact"},
{"id": "n2", "text": "шины поменял осенью", "kind": "fact"},
{"id": "n3", "text": "страховка до декабря", "kind": "fact"}
]
},
{
"id": "ru-pref-028",
"lang": "ru",
"tags": ["preference", "hard", "paraphrase"],
"query": "как мне присылать оповещения",
"want": "n1",
"notes": [
{"id": "n1", "text": "терпеть не могу уведомления со звуком", "kind": "note"},
{"id": "n2", "text": "вибрацию оставь включённой", "kind": "note"},
{"id": "n3", "text": "письма читаю вечером", "kind": "note"}
]
},
{
"id": "ru-silent-029",
"lang": "ru",
"tags": ["silent"],
"query": "во сколько отходит поезд",
"want": "",
"notes": [
{"id": "n1", "text": "кот ест только сухой корм", "kind": "note"},
{"id": "n2", "text": "люблю острую еду", "kind": "note"},
{"id": "n3", "text": "пароли держу в keepassxc", "kind": "note"}
]
},
{
"id": "en-home-030",
"lang": "en",
"tags": ["homelab", "paraphrase"],
"query": "what firmware is on the router",
"want": "n1",
"notes": [
{"id": "n1", "text": "the router runs openwrt", "kind": "note"},
{"id": "n2", "text": "wifi channel is 6", "kind": "note"},
{"id": "n3", "text": "the guest network is off", "kind": "note"}
]
}
]
}
-2
View File
@@ -70,8 +70,6 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
CHECK (resolution_state IN ('none','pending','resolved','ambiguous','not_found'));
CREATE INDEX IF NOT EXISTS idx_facts_entity_id ON facts (entity_id) WHERE entity_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async
`CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364)
}
// migrate applies every migration with a number greater than the DB's current
-45
View File
@@ -28,20 +28,6 @@ const (
NudgeIgnored = "ignored"
)
// SnoozeDuration — how long one `snoozed` outcome keeps its rule quiet.
//
// The nudges table records THAT a snooze happened and when, never for how
// long: nothing upstream can supply a length. ResolveNudge takes only
// (id, outcome, ts), and so do the IPC method and the web/telegram callers
// behind it. So a fixed default it is, rather than a new column no writer
// could fill.
//
// Two hours: longer than every rule's base cooldown (1560m) so a snooze
// actually buys quiet instead of being swallowed by the cooldown, and short
// enough that a snooze the operator forgets about clears the same day. A
// snooze can never outlive this window, so Maven cannot go quiet forever.
const SnoozeDuration = 2 * time.Hour
var (
ErrNudgeNotFound = errors.New("store: nudge not found")
ErrNudgeOutcome = errors.New("store: nudge already resolved")
@@ -152,37 +138,6 @@ func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) {
return out, rows.Err()
}
// SnoozedUntil — per rule, when its most recent snooze runs out. This is the
// read behind the gate's snooze check: the `snoozed` outcome already in the
// nudges table IS the restraint memory, so there is no snooze table.
//
// Rules with no live snooze are absent from the map, which is what the gate
// wants (a missing key means "not snoozed"). Expired snoozes are filtered out
// in SQL, so an old snooze can never come back as a silent forever-mute.
//
// Called every tick (~60s). One indexed lookup over the snoozed rows only.
func (s *Store) SnoozedUntil(ctx context.Context, now time.Time) (map[string]time.Time, error) {
cutoff := now.Add(-SnoozeDuration).UnixMilli()
rows, err := s.db.QueryContext(ctx,
`SELECT rule, MAX(outcome_ts) FROM nudges
WHERE outcome = 'snoozed' AND outcome_ts > ?
GROUP BY rule`, cutoff)
if err != nil {
return nil, fmt.Errorf("snoozed until: %w", err)
}
defer rows.Close()
out := make(map[string]time.Time)
for rows.Next() {
var rule string
var tsMilli int64
if err := rows.Scan(&rule, &tsMilli); err != nil {
return nil, err
}
out[rule] = time.UnixMilli(tsMilli).UTC().Add(SnoozeDuration)
}
return out, rows.Err()
}
// RecentNudges — the newest n nudges across all rules, with outcomes, for the
// monitoring dash. Newest first.
func (s *Store) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
-104
View File
@@ -1,104 +0,0 @@
package store
import (
"context"
"testing"
"time"
)
// snoozeNudge records a nudge and immediately snoozes it at ts.
func snoozeNudge(t *testing.T, s *Store, rule string, ts time.Time) {
t.Helper()
ctx := context.Background()
id, err := s.RecordNudge(ctx, rule, "voice", "drink water", ts)
if err != nil {
t.Fatalf("RecordNudge: %v", err)
}
if err := s.ResolveNudge(ctx, id, NudgeSnoozed, ts); err != nil {
t.Fatalf("ResolveNudge: %v", err)
}
}
func TestSnoozedUntilPerRule(t *testing.T) {
s := newTestStore(t)
now := time.Now().UTC().Truncate(time.Millisecond)
snoozeNudge(t, s, "water", now.Add(-10*time.Minute))
snoozeNudge(t, s, "break", now.Add(-30*time.Minute))
got, err := s.SnoozedUntil(context.Background(), now)
if err != nil {
t.Fatalf("SnoozedUntil: %v", err)
}
if len(got) != 2 {
t.Fatalf("want 2 snoozed rules, got %v", got)
}
wantWater := now.Add(-10 * time.Minute).Add(SnoozeDuration)
if !got["water"].Equal(wantWater) {
t.Fatalf("water until = %v, want %v", got["water"], wantWater)
}
}
// The map must only ever hold the newest snooze for a rule, so a stale one
// can't shorten (or lengthen) the live one.
func TestSnoozedUntilUsesNewestSnooze(t *testing.T) {
s := newTestStore(t)
now := time.Now().UTC().Truncate(time.Millisecond)
snoozeNudge(t, s, "water", now.Add(-90*time.Minute))
snoozeNudge(t, s, "water", now.Add(-5*time.Minute))
got, err := s.SnoozedUntil(context.Background(), now)
if err != nil {
t.Fatalf("SnoozedUntil: %v", err)
}
want := now.Add(-5 * time.Minute).Add(SnoozeDuration)
if !got["water"].Equal(want) {
t.Fatalf("water until = %v, want %v", got["water"], want)
}
}
// A snooze must expire. If this ever regresses Maven goes quiet forever and
// nobody can tell why.
func TestSnoozedUntilExpires(t *testing.T) {
s := newTestStore(t)
now := time.Now().UTC().Truncate(time.Millisecond)
snoozeNudge(t, s, "water", now.Add(-SnoozeDuration-time.Minute))
got, err := s.SnoozedUntil(context.Background(), now)
if err != nil {
t.Fatalf("SnoozedUntil: %v", err)
}
if _, ok := got["water"]; ok {
t.Fatalf("expired snooze still active: %v", got)
}
}
// Other outcomes are not snoozes.
func TestSnoozedUntilIgnoresOtherOutcomes(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
for _, outcome := range []string{NudgeActed, NudgeIgnored} {
id, err := s.RecordNudge(ctx, "water", "voice", "drink water", now)
if err != nil {
t.Fatalf("RecordNudge: %v", err)
}
if err := s.ResolveNudge(ctx, id, outcome, now); err != nil {
t.Fatalf("ResolveNudge: %v", err)
}
}
if _, err := s.RecordNudge(ctx, "break", "voice", "stand up", now); err != nil {
t.Fatalf("RecordNudge: %v", err)
}
got, err := s.SnoozedUntil(ctx, now)
if err != nil {
t.Fatalf("SnoozedUntil: %v", err)
}
if len(got) != 0 {
t.Fatalf("want no snoozes, got %v", got)
}
}