diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 1e8f7bc..b89241c 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -59,6 +59,26 @@ type querySource struct { // sources search text with no notion of a day. When one of them grows a // date parameter, flip its flag here. dateAware bool + + // dest — the destination this source serves, when the cascade named one + // (V-655). Several sources share a destination: the three recall passes and + // the fact-by-key lookup are all SourceRecall, because which of them lands + // the hit is an ordering detail no utterance can name. A source with no + // dest is reachable only by walking the chain. + dest router.Source + + // guesses — this source decides whether the turn is its own by scoring the + // utterance against frozen seeds, rather than by looking something up and + // coming back empty. + // + // The distinction is the whole point of the field. A source that looks can + // be wrong about relevance and still harmless, because the miss shows up as + // no rows. A source that guesses answers whatever it claims: weather has no + // local table to miss against, so "что такое TCP?" became "для какого + // города?". So when the cascade names a destination, the guessers that were + // not named do not get to try. The lookups still run, because a named + // destination is evidence and not a promise. + guesses bool } // querySources is the ordered chain actionQuery walks; first source to claim @@ -67,85 +87,85 @@ type querySource struct { // gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one // line here plus its method; where you put the line is the whole decision. var querySources = []querySource{ - {name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey}, + {name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey, dest: router.SourceRecall}, // Before "calendar" on purpose: both match "…на сегодня", and the plan is // the more specific ask (its matcher requires a plan word), so the calendar // listing would otherwise swallow it. - {name: "day-plan", answer: (*reactiveHandler).queryDayPlan}, + {name: "day-plan", answer: (*reactiveHandler).queryDayPlan, dest: router.SourceCalendar}, // Also before "calendar": "что я обычно делаю по средам?" names a weekday, // and the habit question is the more specific one. Its matcher requires a // habit marker ("обычно", "каждый", …), so a question about this coming // Wednesday still reaches the calendar. - {name: "habits", answer: (*reactiveHandler).queryHabits}, + {name: "habits", answer: (*reactiveHandler).queryHabits, dest: router.SourceCalendar}, // Before "calendar" and before the recall sources: "что мне нужно // сделать?" is a question about the task list, and the notes pass would // otherwise answer it with whatever note happens to be nearest. Its // matcher requires a task noun or an explicit "что … сделать", so a // date-bearing question still reaches the calendar. - {name: "tasks", answer: (*reactiveHandler).queryTasks}, + {name: "tasks", answer: (*reactiveHandler).queryTasks, dest: router.SourceTasks}, // Next to "tasks" and for the same reason: "что требует внимания?" is a // question about the operational state Praxis holds, and it used to fall // through every source to the web search (Vikunja #475). Its matcher needs // an attention marker, and it falls through when Praxis is not configured. - {name: "attention", answer: (*reactiveHandler).queryAttention}, + {name: "attention", answer: (*reactiveHandler).queryAttention, dest: router.SourceAttention, guesses: true}, // Next to "tasks" and for the same reason: "что мне купить?" is a question // about the shopping list, and the recall pass would otherwise answer it // from an old note about the shop. Its matcher needs an explicit list // marker, so "надо бы съездить в магазин" is untouched. - {name: "list", answer: (*reactiveHandler).queryList}, + {name: "list", answer: (*reactiveHandler).queryList, dest: router.SourceList, guesses: true}, // Before the recall sources too: "сколько я потратил?" is a question about // the money facts the poller wrote, and the notes pass would otherwise // answer it from whatever he once said about spending. Its matcher needs a // money noun plus an actual ask, so "я потратил весь день" is untouched. - {name: "money", answer: (*reactiveHandler).queryMoney}, + {name: "money", answer: (*reactiveHandler).queryMoney, dest: router.SourceMoney}, // Also above the recall sources: "что я тебе говорил?" is a question about // the facts he tapped in, and the notes pass would answer it with whatever // note is nearest (Vikunja #456). Its matcher needs both halves of a // history phrase and bails out when he names a topic, so "что я говорил // про сервер" is still recall. - {name: "history", answer: (*reactiveHandler).queryHistory}, + {name: "history", answer: (*reactiveHandler).queryHistory, dest: router.SourceRecall}, // Before the recall sources and before general knowledge: "что нового?" is // a question about the feeds she reads, and general knowledge would answer // it by inventing news. Its matcher needs a feed noun plus an ask, so // "у меня новая лента в инстаграме" is untouched. - {name: "feeds", answer: (*reactiveHandler).queryFeeds}, + {name: "feeds", answer: (*reactiveHandler).queryFeeds, dest: router.SourceFeeds, guesses: true}, // Before "calendar" and before the recall sources: "что включено дома?" is // a question about the house, and the notes pass would otherwise answer it // from whatever he once said about the lights. Its matcher needs a house // marker plus an ask plus a device word, and it bails out on weather // wording, so "какая температура на улице?" still reaches the weather // source. - {name: "home", answer: (*reactiveHandler).queryHome}, + {name: "home", answer: (*reactiveHandler).queryHome, dest: router.SourceHome, guesses: true}, // Next to "home" and for the same reason: "какие устройства в сети?" is a // question about the LAN, and the recall pass would otherwise answer it // from an old note about the router. Its matcher needs a network word plus // an ask plus a device noun, so "интернет не работает" is untouched. - {name: "network", answer: (*reactiveHandler).queryNetwork}, - {name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true}, - {name: "weather", answer: (*reactiveHandler).queryWeather}, + {name: "network", answer: (*reactiveHandler).queryNetwork, dest: router.SourceNetwork, guesses: true}, + {name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true, dest: router.SourceCalendar}, + {name: "weather", answer: (*reactiveHandler).queryWeather, dest: router.SourceWeather, guesses: true}, // A question about her, above the three sources that search his own data // (Vikunja #555). It has no answer anywhere else: below the boundary // SearXNG answers about somebody else's assistant, and above it his notes // answer by proximity — "кто ты" came back from a note of his, measured on // the box, because the recall index has no idea the subject is her. - {name: "self", answer: (*reactiveHandler).querySelf}, - {name: "embed", answer: (*reactiveHandler).queryEmbed}, - {name: "memory", answer: (*reactiveHandler).queryMemory}, - {name: "notes", answer: (*reactiveHandler).queryNotes}, + {name: "self", answer: (*reactiveHandler).querySelf, dest: router.SourceSelf, guesses: true}, + {name: "embed", answer: (*reactiveHandler).queryEmbed, dest: router.SourceRecall}, + {name: "memory", answer: (*reactiveHandler).queryMemory, dest: router.SourceRecall}, + {name: "notes", answer: (*reactiveHandler).queryNotes, dest: router.SourceRecall}, // THE BOUNDARY. Everything above answers from his own data; everything // below answers from the world's. A question about him that got this far // has no answer in his data, and no outside source can supply one, so this // stops the walk rather than let the encyclopedia and the model guess. - {name: "personal", answer: (*reactiveHandler).queryPersonal}, + {name: "personal", answer: (*reactiveHandler).queryPersonal, dest: router.SourceRecall, guesses: true}, // The world, read live. Owner's ruling of 2026-08-02: a metasearch hit beats // a frozen ZIM, so SearXNG asks before Kiwix does. Nothing of his is at // stake by this point — the boundary above already stopped every question // about him, and only the query string leaves the box. - {name: "search", answer: (*reactiveHandler).querySearch}, + {name: "search", answer: (*reactiveHandler).querySearch, dest: router.SourceWorld}, // The offline encyclopedia, now the fallback for when the line is down or // the search comes back empty. It reads the way it always did; what changed // is that it no longer gets first refusal on a world question. - {name: "kiwix", answer: (*reactiveHandler).queryKiwix}, + {name: "kiwix", answer: (*reactiveHandler).queryKiwix, dest: router.SourceWorld}, // LAST before the model answers from memory, and that position is the whole // design (Vikunja #259): everything of his, then the search, then the ZIMs, // and only then a page he named. The model does NOT come first: it @@ -153,8 +173,43 @@ var querySources = []querySource{ // a 1.7B guessing at a page it cannot read is how contents get invented. // This source only claims a turn where he named a URL, so it never competes // with a local answer. - {name: "web", answer: (*reactiveHandler).queryWeb}, - {name: "general-knowledge", answer: (*reactiveHandler).queryGeneral}, + {name: "web", answer: (*reactiveHandler).queryWeb, dest: router.SourceWorld}, + {name: "general-knowledge", answer: (*reactiveHandler).queryGeneral, dest: router.SourceWorld}, +} + +// queryWalk narrows the chain for one turn against the destination the cascade +// named, and says which sources were left out (V-655). +// +// It takes sources OUT and never moves one, which is the whole safety argument. +// The table's order is load-bearing and every comment on it argues a reason +// between two sources; none of those reasons is about this. Above all, the +// order carries "his data first, then the world", and a destination named by a +// model must not be able to reverse that. Naming SourceWorld does not send the +// turn outside — it stops the guessers from claiming it on the way. +// +// What comes out is exactly the sources that guess. Those decide whether a turn +// is theirs by scoring it against frozen seeds, and then answer whatever they +// claimed, because they have no lookup that can come back empty. That is the +// whole of the 2026-08-07 defect: weather claiming "что такое TCP?", the feed +// claiming "какой у меня любимый язык?", the personal boundary claiming "кто +// такой Линус Торвальдс?". The sources that look are all still asked, so a +// wrong destination costs nothing but the guess it prevented. +// +// No destination named ⇒ the table exactly as written, which is what shipped +// before the field existed. That is the floor. The classifier arm names +// nothing, so a box whose model is down routes queries the way it always did. +func queryWalk(dest router.Source) (walk, skipped []querySource) { + if dest == router.SourceUnknown { + return querySources, nil + } + for _, s := range querySources { + if s.guesses && s.dest != dest { + skipped = append(skipped, s) + continue + } + walk = append(walk, s) + } + return walk, skipped } func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string { @@ -164,7 +219,14 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) // (V-564). Finish names everyone below the winner. decision.Expect(ctx, decision.StageQuery, querySourceNames()) rec := decision.From(ctx) - for _, src := range querySources { + walk, skipped := queryWalk(dec.Source) + for _, src := range skipped { + rec.Note(decision.Claim{ + Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked, + Reason: "it decides by similarity and the cascade named " + string(dec.Source), + }) + } + for _, src := range walk { if dec.Continued && !src.dateAware { rec.Note(decision.Claim{ Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked, diff --git a/cmd/mavend/querywalk_test.go b/cmd/mavend/querywalk_test.go new file mode 100644 index 0000000..4476680 --- /dev/null +++ b/cmd/mavend/querywalk_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "testing" + + "github.com/kami/maven/internal/router" +) + +// The floor, and it is the reason a destination is safe to add at all: a box +// whose model is down names nothing, and naming nothing has to walk the chain +// the way it walked before the field existed. +func TestNoDestinationWalksTheWholeChain(t *testing.T) { + walk, skipped := queryWalk(router.SourceUnknown) + if len(skipped) != 0 { + t.Errorf("skipped %d sources with no destination named, want none", len(skipped)) + } + if len(walk) != len(querySources) { + t.Fatalf("walk has %d sources, want the whole table of %d", len(walk), len(querySources)) + } + for i := range walk { + if walk[i].name != querySources[i].name { + t.Fatalf("position %d is %q, want %q", i, walk[i].name, querySources[i].name) + } + } +} + +// The 2026-08-07 defects, one per line. Each is a source that decides by seed +// similarity claiming a turn that was never its own, and then answering it +// because it has no lookup that could come back empty. +func TestANamedDestinationSilencesTheOtherGuessers(t *testing.T) { + cases := []struct { + dest router.Source + utterance string + silenced string + }{ + {router.SourceWorld, "что такое TCP?", "weather"}, + {router.SourceWorld, "сколько будет 17 на 23?", "weather"}, + {router.SourceWorld, "кто такой Линус Торвальдс?", "personal"}, + {router.SourceRecall, "какой у меня любимый язык?", "feeds"}, + {router.SourceCalendar, "что в календаре на завтра?", "weather"}, + } + for _, c := range cases { + walk, skipped := queryWalk(c.dest) + if inWalk(walk, c.silenced) { + t.Errorf("%q named %q: %q is still asked", c.utterance, c.dest, c.silenced) + } + if !inWalk(skipped, c.silenced) { + t.Errorf("%q named %q: %q is missing from the record of who was skipped", + c.utterance, c.dest, c.silenced) + } + } +} + +// Naming the world must not send the turn outside. His notes, his facts and the +// boundary in front of them are the invariant CLAUDE.md states as "the owner's +// data first, then the world", and a destination a model wrote must not be able +// to reverse it. +func TestNamingTheWorldStillReadsHisDataFirst(t *testing.T) { + walk, _ := queryWalk(router.SourceWorld) + for _, look := range []string{"fact-by-key", "embed", "memory", "notes"} { + if !inWalk(walk, look) { + t.Errorf("%q was dropped; only the sources that guess may be dropped", look) + } + } + if posOf(walk, "notes") > posOf(walk, "search") { + t.Error("search is asked before his notes are") + } + if posOf(walk, "search") < 0 { + t.Fatal("search is not in the walk at all") + } +} + +// The boundary belongs to his data, so naming recall keeps it. That is what +// makes "какой у меня любимый язык?" answer "не нашла у тебя такой записи" +// rather than reaching SearXNG once nothing local had it. +func TestNamingRecallKeepsTheBoundary(t *testing.T) { + walk, _ := queryWalk(router.SourceRecall) + if !inWalk(walk, "personal") { + t.Fatal("the personal boundary was skipped on a turn named for his own data") + } + if posOf(walk, "personal") > posOf(walk, "search") { + t.Error("the boundary no longer sits in front of the world") + } +} + +// Whatever the destination, the walk is a subsequence of the table. Every +// comment on that table argues an order between two sources, and none of those +// reasons is about this field. +func TestTheWalkNeverReordersTheTable(t *testing.T) { + for _, dest := range append([]router.Source{router.SourceUnknown}, router.Sources...) { + walk, skipped := queryWalk(dest) + if len(walk)+len(skipped) != len(querySources) { + t.Errorf("%q: %d walked + %d skipped, want %d", dest, len(walk), len(skipped), len(querySources)) + } + last := -1 + for _, s := range walk { + at := posOf(querySources, s.name) + if at <= last { + t.Errorf("%q: %q is out of table order", dest, s.name) + } + last = at + } + } +} + +func inWalk(list []querySource, name string) bool { return posOf(list, name) >= 0 } + +func posOf(list []querySource, name string) int { + for i, s := range list { + if s.name == name { + return i + } + } + return -1 +} diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 95ad503..9e308e9 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -401,6 +401,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, grammars = append(grammars, router.AgendaQueryGrammars()...) // Same reason as the agenda rules, for the feeds: "что нового в лентах?" // routed system and answered "пока не умею" (Vikunja #474). + // After the agenda rules, which are the narrower claim, and BEFORE the feed + // and list rules, which are not: "что такое лента" is a definition question + // and the feed rule would take it on the noun alone (V-655). + grammars = append(grammars, router.WorldQueryGrammars()...) grammars = append(grammars, router.FeedQueryGrammar()) // The list side of the same exposure: a phrasing with no possessive in it // ("список дел") routed system and never reached queryTasks (Vikunja #467).