Merge pull request 'Query source is a routing decision made outside the router' (#199) from task/655-query-source-is-a-routing-decision-made into master

This commit was merged in pull request #199.
This commit is contained in:
2026-08-08 19:03:06 +02:00
9 changed files with 529 additions and 28 deletions
+47
View File
@@ -357,6 +357,53 @@ Adding a rung to the ladder
in `runTurn` means adding its name to `preRouteLadder` in
`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record.
**A route now says where the answer lives, not only that the turn is a question**
(V-655, 07-08-2026). `query` was a shrug. The cascade sorted an utterance into one of
seven intents, with stage 0, the resident model and the classifier behind it. Then
`IntentQuery` handed the turn to `querySources` in the daemon. That is twenty-two branches
deciding by seed similarity in a fixed order. It has no fixture and no accuracy
number, no model arm and no floor. `Decision.Source` (`internal/router/source.go`) is
the second half of the route. Twelve destinations, not twenty-two. The three recall
passes plus `fact-by-key` are one destination from outside. So are search, Kiwix and
the URL reader.
**`SourceUnknown` is a real value and it is the floor.** Nothing named a destination,
so the daemon walks the whole chain. That is byte-for-byte what shipped before the
field existed. The classifier arm names nothing, so a box whose model is down routes
queries exactly as it did.
`queryWalk` in `cmd/mavend/actions_query.go` takes sources **out** and moves none.
That is the safety argument and it is not negotiable. The table's order is
load-bearing. Every comment on it argues a reason between two sources, and above all
it carries "the owner's data first, then the world". Naming `SourceWorld` does not
send the turn outside. His notes, his facts and the personal boundary still run first.
What comes out is only the sources that **guess**. Those decide a turn is theirs by
cosine against frozen seeds, then answer whatever they claimed. They hold no table
that could come back empty. Weather is the pure case and has no local data at
all. It was measured on the box on 2026-08-07
(`docs/evals/2026-08-07-week-of-usage.md` section 4). It answered both "что такое
TCP?" and "сколько будет 17 на 23?" with "для какого города?". The feed answered "какой у меня любимый язык?" with kernel headlines.
The personal boundary answered "кто такой Линус Торвальдс?" with "не нашла у тебя
такой записи". A source that guesses is marked `guesses: true` in the table. One that
looks is not, and it is always asked.
Stage 0 fills the destination where a rule already knows it. `WorldQueryGrammars()`
(`internal/router/worldquery.go`) claims "что такое X" and "сколько будет 17 на 23".
It is wired after the agenda rules and **before** the feed and list rules.
"что такое лента" is a definition question, and the feed rule would take it on the
noun alone.
`calendar-query` and `event-time-query` name the calendar. The possessive agenda rules
deliberately do not. "что у меня в списке покупок" matches `agenda-query`, and naming
the calendar there would take the list source off the turn.
Fixture unchanged at **69/91 classifier+ONNX**, measured both sides. That is the
expected result, because it scores intent and no case here changes intent. **The
destination has no fixture yet, so it has no accuracy number.** That and the model arm
are the follow-ups. The field is designed so a decider naming nothing costs nothing.
It lands on V-546. Intent, mood and BIO slot tags were already three heads on one
forward pass of the resident e5-small. Destination is a fourth head on the same pass.
## LLM output contract
All phrasing paths emit `{"response":"...","mood":"..."}`, with fallback to plain text when
+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).
+6
View File
@@ -105,6 +105,12 @@ type Decision struct {
Slots Slots
Clarify bool // stage 3: below threshold — ask, don't guess
// Source — where the answer lives, for a query. The second half of the
// route, and empty on every other intent. SourceUnknown means no decider
// named one and the daemon walks its whole chain, which is what shipped
// before this field existed. See source.go for why it is twelve values.
Source Source
// Continued — this decision was rebuilt from the previous turn rather
// than routed, because the utterance was an ellipsis ("а завтра?").
// Handlers use it to know that Slots.Text is the PREVIOUS turn's topic
+78
View File
@@ -0,0 +1,78 @@
package router
// Source — where the answer to a query lives. It is the second half of a
// routing decision and it used to be made outside the router entirely (V-655).
//
// The cascade sorted an utterance into one of seven intents with stage 0 rules,
// the resident model and the classifier behind it, a fixture measuring it and
// the decision trace recording it. Then IntentQuery handed the turn to
// querySources in the daemon, a chain of twenty-two branches deciding by seed
// similarity in a fixed order, with none of that. So the careful sorter did the
// easy half and the sloppy one did the hard half: on 2026-08-07 weather claimed
// "что такое TCP?" and answered "для какого города?", because weather read one
// percent closer to the turn than the pile of leftover seeds did, and one
// percent was enough. Search would have answered it and search was never asked.
//
// "query" is not a destination. It is a shrug. This is the field that says
// where to look.
//
// # Why twelve and not twenty-two
//
// A destination is what a decider can plausibly name from the utterance alone,
// not one entry per source. Three of the daemon's sources are successive passes
// over his own words and a fourth reads the facts by key: which of them lands
// the hit is an ordering detail inside the chain, and no utterance says. They
// are SourceRecall together. The same goes for the metasearch, the offline
// encyclopedia and a page he named by URL, which are SourceWorld.
//
// # Empty is a real value and it is the floor
//
// SourceUnknown means nobody decided. The daemon then walks the whole chain in
// its original order, which is the behaviour that shipped before this field
// existed. So the classifier arm sets nothing and costs nothing, and a box
// whose model is down routes queries exactly as it did.
type Source string
const (
// SourceUnknown — no decider named a destination. Walk the chain.
SourceUnknown Source = ""
// His own data.
SourceRecall Source = "recall" // notes, facts and what he has said before
SourceCalendar Source = "calendar" // events, and the only date-aware destination
SourceTasks Source = "tasks" // the task list
SourceList Source = "list" // the shopping and other named lists
SourceMoney Source = "money" // the spending facts the poller writes
// The surroundings.
SourceWeather Source = "weather" // the forecast for a place
SourceHome Source = "home" // lights, devices, the house
SourceNetwork Source = "network" // the LAN and what is on it
SourceFeeds Source = "feeds" // the RSS she reads
SourceAttention Source = "attention" // what Praxis says needs looking at
// Everything else.
SourceSelf Source = "self" // a question about Maven herself
SourceWorld Source = "world" // search, the ZIMs, a page he named
)
// Sources — every destination a decider may name, in a fixed order so a prompt,
// a grammar table and a test all read the same list. SourceUnknown is not a
// member: it is the absence of a choice, not one of the choices.
var Sources = []Source{
SourceRecall, SourceCalendar, SourceTasks, SourceList, SourceMoney,
SourceWeather, SourceHome, SourceNetwork, SourceFeeds, SourceAttention,
SourceSelf, SourceWorld,
}
// ValidSource reports whether s is one a decider may name. Anything else,
// including a destination invented by a model, is dropped back to
// SourceUnknown by the caller rather than trusted.
func ValidSource(s Source) bool {
for _, known := range Sources {
if s == known {
return true
}
}
return false
}
+20 -5
View File
@@ -187,9 +187,11 @@ func SystemTimeDateGrammars() []Grammar {
// written ("the clock/date system rule must not swallow it"); the daemon
// disagreed with the fixture and the daemon was wrong.
//
// Routing, not answering. These set the intent and nothing else — which source
// in the query chain claims the turn stays the chain's decision, and a
// question with no date still falls through queryCalendar to recall.
// Routing, not answering. Two of the five also name the calendar as the
// destination (V-655), which narrows who may GUESS their way onto the turn and
// claims nothing. Every source that looks something up still runs, in the order
// it always did, so a question with no date still falls through queryCalendar
// to recall.
//
// Deliberately not folded into SystemTimeDateGrammars: those exist to send
// utterances TO system, these exist to keep utterances OUT of it, and one
@@ -199,9 +201,15 @@ func AgendaQueryGrammars() []Grammar {
{
// An explicit calendar noun is unambiguous wherever it appears:
// "что в календаре на завтра", "покажи расписание на среду".
//
// The one agenda rule that names its destination, because an
// explicit calendar noun leaves nothing to weigh (V-655). The
// possessive rules below deliberately do not: "что у меня в списке
// покупок" matches agenda-query, and naming the calendar there
// would take the list source off the turn.
Name: "calendar-query",
Pattern: regexp.MustCompile(`(?i)(календар|расписани|повестк)`),
Build: agendaQueryBuild,
Build: queryTo(SourceCalendar),
},
{
// The agenda phrasing with no calendar noun. Anchored at the start
@@ -251,9 +259,11 @@ func AgendaQueryGrammars() []Grammar {
// "во сколько созвон". He is asking when something on his calendar
// happens, and the noun is the only signal. Closed list, so "когда
// битва при Ватерлоо" is still a world question.
// Names the calendar (V-655): the noun list is closed and every
// member of it is an event, so there is nothing else to weigh.
Name: "event-time-query",
Pattern: regexp.MustCompile(`(?i)^\s*(когда|во\s+сколько|в\s+котором\s+часу)\s+(будет\s+|у\s+нас\s+)?(планёрк|планерк|встреч|созвон|митинг|совещани|звонок|созвон|приём|прием|интервью|собеседовани|тренировк|урок|занятие|пара)[а-я]*(\s|[?!.]|$)`),
Build: agendaQueryBuild,
Build: queryTo(SourceCalendar),
},
}
}
@@ -340,6 +350,11 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
Intent: IntentQuery,
Confidence: 1.0,
Slots: Slots{Text: topic},
// The world, because that is the shape this asks for and the rule has
// already declined the two cases where it is not: entertainment, and
// questions about her (V-655). His own notes are still read first — a
// destination narrows who may guess and reorders nothing.
Source: SourceWorld,
}, true
}
+86
View File
@@ -0,0 +1,86 @@
package router
import "regexp"
// WorldQueryGrammars — stage-0 rules for the two question shapes that name the
// world in their own words, and say so plainly enough that no scorer is needed
// (V-655).
//
// They exist because of what happens when nothing deterministic claims these.
// Measured on the box on 2026-08-07 (docs/evals/2026-08-07-week-of-usage.md,
// section 4): "что такое TCP?" and "сколько будет 17 на 23?" were both answered
// "для какого города?", and "кто такой Линус Торвальдс?" was answered "не знаю —
// не нашла у тебя такой записи". None of those three is about him, about the
// weather, or about anything on this box.
//
// The mechanism is the destination, not the answer. Naming SourceWorld does not
// send the turn outside and does not skip a single source that looks something
// up: his notes, his facts and the personal boundary all still run first, in the
// order they always did. What it does is stop the sources that claim on seed
// similarity from taking the turn on the way past. Weather cannot claim a
// question about a protocol once the utterance has said which side it is on.
//
// Both patterns are spelled out here rather than drawn from internal/lexicon,
// which is the same call the agenda rules made: these are interrogative FRAMES
// of two words, not a closed class of single words, and the lexicon holds
// classes. Nothing here is a stem pattern over open vocabulary — the variable
// part of each rule is the topic, and the rule reads none of it.
func WorldQueryGrammars() []Grammar {
return []Grammar{
{
// "что такое X", "кто такой X". A request for what a thing or a
// person IS, which his own data can answer and usually cannot.
//
// The topic is deliberately not captured into Slots.Text. Every
// source below reads the utterance, "что такое TCP?" is already the
// best query string for it, and the agenda rules make the same call
// for the same reason.
Name: "definition-query",
Pattern: definitionQueryPattern,
Build: queryTo(SourceWorld),
},
{
// "сколько будет 17 на 23", "сколько будет 2+2". Arithmetic, which
// the metasearch answers and no local source holds. The digits are
// what make it arithmetic: "сколько будет гостей" names no number
// and is a question about his evening.
Name: "arithmetic-query",
Pattern: arithmeticQueryPattern,
Build: queryTo(SourceWorld),
},
}
}
// definitionQueryPattern — anchored at the start, because "напомни узнать что
// такое TCP" is a reminder that happens to contain the frame.
//
// (\s|[?!.]|$) and not \b: Go's \b is ASCII-only and never fires after a
// Cyrillic letter, so the ASCII form silently matches nothing. The agenda rules
// carry the same note.
var definitionQueryPattern = regexp.MustCompile(
`(?i)^\s*(что\s+так(ое|ая)|кто\s+так(ой|ая|ие)|what\s+is|who\s+is)(\s|[?!.]|$)`)
// arithmeticQueryPattern — the ask, then a digit somewhere after it. Loose on
// what sits between them on purpose: the operator is spoken half a dozen ways
// ("на", "умножить на", "плюс", "+") and reading them is the calculator's job,
// not this rule's. All this decides is which side of the boundary the turn is
// on.
var arithmeticQueryPattern = regexp.MustCompile(
`(?i)^\s*(сколько\s+будет|посчитай|вычисли|how\s+much\s+is)\s.*\d`)
// queryTo builds a stage-0 query Decision that names where the answer lives.
//
// The utterance travels intact and no slot is filled, which is the same
// contract agendaQueryBuild has: confidence 1.0 on the intent and the
// destination, and every source below still decides for itself whether it has
// an answer. Naming a destination narrows who may guess. It promises nothing.
func queryTo(dest Source) func([]string) (Decision, bool) {
return func([]string) (Decision, bool) {
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1.0,
Source: dest,
}, true
}
}
+88
View File
@@ -0,0 +1,88 @@
package router
import "testing"
// The three utterances from the 2026-08-07 week on the box that no local source
// could answer and three different local sources claimed anyway. Stage 0 has to
// say which side of the boundary they are on, because by the time the chain is
// walking, the only thing separating them from a weather forecast is a cosine.
func TestAWorldQuestionNamesTheWorld(t *testing.T) {
cases := []struct {
utterance string
rule string
}{
{"что такое TCP?", "definition-query"},
{"кто такой Линус Торвальдс?", "definition-query"},
{"что такая мембрана", "definition-query"},
{"кто такая Ада Лавлейс?", "definition-query"},
{"what is TCP?", "definition-query"},
{"сколько будет 17 на 23?", "arithmetic-query"},
{"посчитай 2+2", "arithmetic-query"},
{"сколько будет 5 умножить на 6", "arithmetic-query"},
}
for _, c := range cases {
dec, rule, ok := matchWorldQuery(c.utterance)
if !ok {
t.Errorf("%q: no world rule claimed it", c.utterance)
continue
}
if rule != c.rule {
t.Errorf("%q: claimed by %q, want %q", c.utterance, rule, c.rule)
}
if dec.Intent != IntentQuery {
t.Errorf("%q: intent %q, want query", c.utterance, dec.Intent)
}
if dec.Source != SourceWorld {
t.Errorf("%q: source %q, want %q", c.utterance, dec.Source, SourceWorld)
}
}
}
// The frame has to be the whole opening or the rule is reading somebody else's
// sentence. Every case here contains a world-question shape and is not one.
func TestAWorldRuleDeclinesWhatIsNotItsShape(t *testing.T) {
cases := []struct {
utterance string
why string
}{
{"напомни узнать что такое TCP", "a reminder that happens to quote the frame"},
{"запиши что такое TCP", "a capture that happens to quote the frame"},
{"сколько будет гостей", "an ask with no number is not arithmetic"},
{"что у меня сегодня?", "his agenda, and the agenda rules own it"},
{"кто там?", "not the frame"},
{"посчитай расходы", "no number, so the money source keeps it"},
}
for _, c := range cases {
if _, rule, ok := matchWorldQuery(c.utterance); ok {
t.Errorf("%q: claimed by %q, want no claim — %s", c.utterance, rule, c.why)
}
}
}
// The destination is advice about who may guess, never a filled slot. A rule
// that quietly captured the topic would change what every source below reads.
func TestNamingTheWorldFillsNoSlot(t *testing.T) {
dec, _, ok := matchWorldQuery("что такое TCP?")
if !ok {
t.Fatal("definition-query did not claim it")
}
if dec.Slots.Text != "" || dec.Slots.HasTime || dec.Slots.HasFn || dec.Slots.HasKey {
t.Errorf("slots = %+v, want none filled", dec.Slots)
}
if dec.Confidence != 1.0 {
t.Errorf("confidence = %v, want 1.0 for a stage-0 match", dec.Confidence)
}
}
func matchWorldQuery(utterance string) (Decision, string, bool) {
for _, g := range WorldQueryGrammars() {
m := g.Pattern.FindStringSubmatch(utterance)
if m == nil {
continue
}
if dec, ok := g.Build(m); ok {
return dec, g.Name, true
}
}
return Decision{}, "", false
}