diff --git a/CLAUDE.md b/CLAUDE.md index aaf08d4..3bfc536 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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). diff --git a/internal/router/intent.go b/internal/router/intent.go index df15fea..43b0b1c 100644 --- a/internal/router/intent.go +++ b/internal/router/intent.go @@ -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 diff --git a/internal/router/source.go b/internal/router/source.go new file mode 100644 index 0000000..13e9273 --- /dev/null +++ b/internal/router/source.go @@ -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 +} diff --git a/internal/router/stage0.go b/internal/router/stage0.go index 1a4c117..ef4a36a 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -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 } diff --git a/internal/router/worldquery.go b/internal/router/worldquery.go new file mode 100644 index 0000000..c458daf --- /dev/null +++ b/internal/router/worldquery.go @@ -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 + } +} diff --git a/internal/router/worldquery_test.go b/internal/router/worldquery_test.go new file mode 100644 index 0000000..bd353d0 --- /dev/null +++ b/internal/router/worldquery_test.go @@ -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 +}