Query source is a routing decision made outside the router #199

Merged
kami merged 3 commits from task/655-query-source-is-a-routing-decision-made into master 2026-08-08 19:03:07 +02:00
3 changed files with 204 additions and 23 deletions
Showing only changes of commit ab3e818bb9 - Show all commits
+85 -23
View File
@@ -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,
+115
View File
@@ -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
}
+4
View File
@@ -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).