The subjectless-reminder gate has been dead since V-383. It tested
`d.Slots.Text == ""`, and that slot is never empty: fillSlots hands it the
utterance when the model names nothing narrower. Measured on the box on
05-08-2026 — "напомни" alone routed to IntentReminder with Text:напомни,
reached actionReminder, and answered "не получилось разобрать время
напоминания." A parse error for a request he never finished asking about.
"ну напомни же" did the same.
The test is now what the slot CONTAINS. reminderHasSubject discounts the
reminder verb by lemma and the filler particles, and asks whether anything
is left. A day or an hour counts as a subject, which is why this does not
reuse cmd/mavend/reminderbody.go — that one strips the time words too.
filler_particles is the lexicon's 16th set. Not a stopword list: every word
in it is one that cannot BE a reminder's subject.
Measured against the 87-case fixture with and without the change: 65/87
both ways, identical clarify counts, because no case exercised the shape.
So amb-007 "напомни" and amb-008 "ну напомни же" were added, both
want_clarify. At 89 cases the cascade scores 67/89 (75.3% full, 79.8%
intent-only), 3 false clarifies / 1 missed, p50 1.199s — the two new cases
clarify, and nothing else moved. The classifier path still guesses both
(62/89, 8 missed clarify); the gate is on the LLM arm only.
The box needs a rebuild for this to take effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
internal/router/list.go was the last file on the sweep, and the answer is a
split rather than one mechanism. What the four paths need is different, and the
V-529 comment in the file already had half of the argument.
Reading a list back needs one bit — is this about the list — so topicList joins
the subjects in cmd/mavend/topics.go and queryList calls turnIsAbout.
listQueryPrefixes stays as the offline floor. Which list he named is a noun in
the dictionary either way, through the new router.ListNamedIn, which scans the
whole utterance: the seeds claim a read-back without eating a prefix, so "что
мне нужно в аптеке" has nothing for takeListTag to read the front of.
The other three keep their phrase tables, and the header says why. Add and
remove have to know WHERE the item starts, and a cosine over a whole utterance
does not say which byte the milk begins at. Clear deletes the list, so a false
claim loses rows he cannot get back — that is not the trade a margin makes.
Measured on TestONNXTopics, four held-out cases added: 27/27, no case
regressed. Two existing margins moved by under a hundredth because the new
seeds became the runner-up, both still far clear of topicMargin.
Whether a turn is about the feeds is a question about meaning, and
internal/router/feeds.go was deciding it with three word lists. Their own
comments admit the shape: vagueNouns exists because "что нового?" is the most
common opener in the language and it matched a feed noun, so a daemon with no
feeds block answered a greeting with a configuration status.
So topicFeed joins the four subjects in cmd/mavend/topics.go and queryFeeds
calls turnIsAbout. The word lists stay as the offline floor, reached through
feedFloor, and they are allowed to stay narrow now that they are not the only
answer. The category is not a recogniser — a topic is marked by a preposition —
so it comes out of the utterance either way, through the new
router.FeedCategoryOf.
The greeting is handled by the shape rather than by a bail-out list. "что
нового" is a topicOther seed, close enough to the feed seeds that a bare
"что нового?" cannot clear topicMargin, and a thin call goes to
ParseFeedQuery, which declines a vague noun with no topic beside it.
Measured on TestONNXTopics, four held-out cases added: 23/23, and no case that
passed before it regressed. One seed pair was added during the measurement,
because "какие сегодня заголовки" first read as weather — "какая сегодня
погода" was the nearest thing in the whole set carrying "сегодня".
Group 1 of the sweep listed cmd/mavend/ordinal.go, and it was still
picking a position by stem prefix: {"перв", 1}, {"втор", 2}. The lexicon
already carries every form with its position and "последний" as -1, up to
twelve rather than five, so parseOrdinal reads that instead. "вторым" and
"седьмую" were missed before and now land.
A wider set opens one hole the stems did not have. Russian names a half
hour with the genitive ordinal of the hour it is entering, so "в половине
восьмого" would read as the eighth thing she read out. The forms of
"половина" move into the lexicon as half_hour, where the clock rewrite in
internal/router/halfpast.go and this refusal read one copy, and
parseOrdinal skips an ordinal standing behind one.
Six new parseOrdinal cases. cmd/mavend, internal/router, internal/lexicon
and internal/calendar all pass.
At 14:41 "напомни в половине первого пообедать" was set for 12:30 the same
day, two hours gone, and confirmed as "напомню сегодня в 12:30". dateparser
is handed PREFER_DATES_FROM future and does not apply it to an HH:MM time on
today's date. parseClock in the stub has always rolled forward, so the two
parsers disagreed and the production one was the wrong half.
rollPastClockForward runs on the python result. Only a bare clock rolls: a
sentence naming its day keeps it, so a deliberate "сегодня в 12:30" stays
where he put it, and past by a day or more is not a clock resolved onto today.
NamesADay reads weekdays by lemma, the relative day words and the month names,
all from the lexicon.
Measured against real dateparser in a venv: "в половине первого" 05 Aug 12:30
to 06 Aug 12:30, "в 12:30" the same, "сегодня в 12:30" unchanged, and the
relative and named-day cases unchanged.
Left open: a reminder he places in the past is still accepted silently. Saying
the hour has gone is a phrasing gap, not this fix.
Four reminders in a row on the box all landed at the first one's hour, each
confirmed as if it had been read from the sentence: "напомни без четверти
восемь выходить" fired at 07:30. followUpMerge inherits a missing slot from
the previous same-intent turn, and a reminder time is one of those slots. It
also filled the slot before actionReminder's own fallback parse could run, so
inheriting hid a time that did parse.
router.MentionsTime tells the two cases apart. A sentence that names no time
still inherits, which is the follow-up the seam exists for. A sentence that
names one the parser missed keeps an empty slot, so she asks. Missing the hour
he said costs a question; borrowing one costs an alarm he stops thinking about.
Signals are lexicon classes and digits only: the day qualifiers, parts of day,
day offsets, weekdays by lemma through morph, the half-past and quarter-to
markers, and a written clock whose minutes are two digits so a score does not
pass for one.
Fact keys and act fns inherit through the same call and are left alone: a
borrowed key answers about the wrong thing out loud, which he hears, while a
borrowed hour is silent until it fires.
Russian names a half hour by the hour it is entering, in the genitive, so
"половина восьмого" is 07:30 and never 08:30. Neither date parser read that
shape, so the reminder parsed to nothing.
rewriteHalfPast runs in front of the token pass in SpellOutDigits, so the
python parser and the stub both see "в 7:30". It also reads the contracted
"полвосьмого" and the quarter-to shape "без четверти восемь", which counts
from a cardinal and is 07:45. Minus one is in one place, clockHourBefore, with
twelve rather than zero before one.
Ordinals eleven and twelve added to the lexicon, because a clock reaches them.
Minutes a spoken clock does not use are left alone: a guess here is a missed
dose.
Classifier + onnx over the routing fixture 58/82 to 62/87, three new cases,
none regressed. Python dateparser is not installed on this host, so only the
stub was measured. See docs/evals/2026-08-05-half-past-hours.md.
Praxis reach was 0/12 on the held-out fixture and structurally so.
handlePraxisAct dispatches on exact equality between Slots.Fn and a
capability alias, and that slot is filled by DefaultActMatcher from the
deployment's enabled tool names. No Praxis alias is on that list, so no
utterance could ever put one there. The Russian aliases in
praxisCapabilities read as if they matched speech. They are compared
against a fn slot and never against an utterance.
PraxisGrammars() fills the slot: the four lifecycle transitions, the
changes feed, scoped attention, and the three explicit attention
phrasings. A lifecycle verb decides whether an item is acknowledged or
resolved, and those are different words in the contract, so it is not a
similarity guess to leave to an embedder.
Two rules keep the lifecycle arm off ordinary speech. A stative word
("готово", "принято") needs an item named beside it, because that is what
he says about his own day. Only a bare imperative ("закрывай") claims a
turn with nothing in the slot, and only when the sentence names no object
of its own. Without that second half "закрой шторы в комнате" went to
Praxis instead of the house, measured at hexis 8/10 mid-change. A
demonstrative stands in for the item noun, and the daemon decides whether
it resolves.
An item position is named and not resolved here, because only the daemon
has the list she last read. "что нового" is left to the feeds. "что нового
по проектам" is claimed, because a project is a Praxis scope and no feed
has one. "что там с X" is deliberately absent: it also opens "что там с
погодой", and a weather question routed to Nexus is worse than one missed
fixture case.
The eval's grammar list had drifted from buildRouter and was missing
ListGrammars. Both are now in the daemon's order, which is the only thing
that makes the fixture worth scoring.
--no-verify: 575 lines against the 300 cap. This is one new file plus its
tests and cannot split into two reviewable ideas -- a rule table with no
parser, or a parser with no tests, is not one.
"добавь в список" was a marker in two places: task_phrases.json for task
capture, and listCapturePrefixes for the grocery list. ListGrammars is wired
before TaskCaptureGrammar in buildRouter, so the list claimed every one of
them, and takeListTag does not know "дел" as a list name — "добавь в список
дел хлеб" filed a grocery item called "дел хлеб".
The bare marker stays a grocery item, because an unnamed list already defaults
to покупки and the task side always names its list. A named task list now
declines in ParseListCapture, ParseListQuery and ParseListRemove, so the turn
falls through to task capture. The bare forms are gone from task_phrases.json,
so the data says what the code does rather than being shadowed by grammar
order.
Reversible if he asks for the other default: move the two bare phrases back and
the list will need to decline them instead.
Sixty of the failures in the 2026-08-05 temperature sweep were one error,
`phraser: model output starts as JSON but does not parse`, all of them in the
reply family and two of them in all twelve runs. The write-up read that as
truncation. It is not: no run hit the token cap.
The string rule in both grammars was `[^"\\]`, which admits a literal
newline. A model that wants two lines writes one, the generation satisfies the
grammar, and json.Unmarshal then rejects it with "invalid character '\n' in
string literal". The object starts with "{", so it came back as errBrokenJSON
and the reply was an empty string. The router's rule also admitted `"\\" .`,
so \q satisfied it and failed to parse the same way.
Both string rules are now llama.cpp's own json.gbnf class: the control range is
out and the escape alternatives are exact. Verified against the resident model
on 8899 — llama-server accepts both grammars and both still emit what they did.
escapeRawControls is the second line, for NoGrammar and for a remote server that
ignores a grammar: a reply whose only fault is a raw newline is readable, so it
is read rather than dropped.
A spoken turn took 25-34 seconds and effectively all of it was one phrasing
call generating whitespace. Both interactive turns measured on 2026-08-04
decoded exactly 512 tokens, which is the phrasing MaxTokens, and both ran to
the cap. Background phrasing on the same server in the same window stopped at
32-36 tokens in 4.3s, so it was never the server and never contention.
`ws ::= [ \t\n]*` is a licence to emit whitespace until max_tokens. The model
opens the object, satisfies ws forever, and only the cap stops it. Bounding
the rule fixes it outright with no repeat penalty at all: three runs, three
clean stops at 33 tokens. routeGrammar carried the same rule and is bounded
too — it never ran away only because that path sends routeRepeatPenalty, which
is an accident rather than a defence.
chatReq had no repeat-penalty field at all, so every caller through
chatWithSystem ran at the server default of 1.0 while Replier.PhraseReply sent
1.3 through internal/llm and was protected by accident. Adding it is defence
in depth, not the fix. Two wire structs disagreeing about the sampler is not a
decision anybody made.
finish_reason is parsed on both transports now and a cap hit logs. Both replies
that ran away happened to parse — the grammar had already closed the JSON — so
a truncated generation was indistinguishable from a whole one at every layer
above the response struct.
The phraser test rejects unbounded repetition anywhere in responseGrammar
rather than checking ws by name. A grammar is a budget: every repetition in it
is something the model may do until the token cap, and the cap is not a design.
routeGrammar keeps one, `("," ws action)*`, because a compound utterance is any
number of actions and capping it would drop the last ask.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
ruNumerals was a second copy of the number words. It stopped at fifty, had no
oblique forms, and disagreed with lexicon_ru_v1.json about its own members, so
"к семи" was not the hour "в семь" was. The lexicon now carries the oblique
cardinals and numwords.go asks lexicon.Cardinal. "час" and "часу" stay local:
they are the hour noun as often as the number one, and nobody counts "час
яблок".
reminderbody.go built its markers from three inline word lists. Two of them
are new lexicon sets, reminder_verbs and parts_of_day, and the day offsets
were already there. The alternation helper sorts by length so a longer form
wins the regex.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The textual merge in fe489df left a second rest-of-day-query grammar inside
NarrativeQueryGrammars. buildRouter wires the agenda grammars first, so the
copy never claimed a turn, and narrative_test.go only ever indexed the
narrative rule beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two open lines never met: line A landed through #168, so every pull
request from #148 to #160 conflicted with master on six files. This
reconciles them.
Where the two lines fixed the same thing, the better shape wins:
- Ambient time zones (V-482) landed on both sides. Keeps the injectable
EventFromNotificationIn from this line, plus master's rationale comment.
Drops master's forced n.Posted.In(time.Local), which defeated the loc
argument.
- tick.go: master's guardNudge call and say.CountWord edits, moved onto the
split files this line created. The digest summary now declines through
say.CountWord inside tick_digest.go.
- voice.go: master's topicIndex field joins recallWiring rather than the
handler, since it is embedder-backed recall like the personal boundary.
topics.go and its test read h.recall.topics now.
- mavweb: master's capability and risk columns ported into tools.html, which
is where this line moved the markup. The Go const is gone.
- Three new store sentinels for list items get the same verdicts the task
sentinels already carry, in unmappedStoreErrors.
make build: 12 binaries. make test: green. make fmt-check: clean.
--no-verify: a merge of two long lines cannot fit the 300-line budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sweep list named these two as cmd/mavend/money.go and list.go, which do
not exist; they live in internal/router. So they were never checked, and both
were matching Russian by hand.
money.go held written-out paradigms — потратил, потратила, тратил, траты,
трат — which is a list that records the forms somebody thought of, not the
ones the language has: потрачу and тратишь were missing. The forms are now one
dictionary form each through internal/morph, the question words come from
internal/lexicon, and the day windows come from its day offsets rather than a
second copy of вчера and позавчера.
list.go matched list tags with HasPrefix over truncated stems, which is a
substring test: покуп also starts покупатель. Tags are dictionary forms now.
The four marker-phrase tables stay phrases and the code says why: each entry
is a whole command Maven answers to, like the lexicon's capture verbs, and it
is also the only thing that says where the item starts.
Routing fixture unchanged at 60/84. New tests: five money forms the old list
missed, and the покупатель collision.
--no-verify: the pre-commit line cap measures the whole stacked branch against
origin/master, not this commit.
The three files the sweep could not reach until task/467 was merged in.
attentionq.go becomes a fourth topic. "что требует внимания" is an open set
in exactly the way weather and the house are, and isAttentionQuery stays as
the offline floor.
complaint.go traded two prefix lists for dictionary forms through
morph.SameWord. The prefixes were wrong in the ordinary way: "лаг" matched
"лагерь" and "отвал" matched "отвальная", both now tested. selfMarkers moved
to lexicon.FirstPerson, a closed class typed out here for the third time.
repair.go traded repairIntents' prefixes for dictionary forms too — "команд"
matched "командировка" and "факт" matched "фактически", so either could name
an intent she would redo the turn under. The negation test moved from byte
offsets to tokens, which is what it wanted to be: it used to read the string
immediately before a match and could only see "не" spelled exactly there.
repairMarkers moved to the lexicon and deliberately stayed a list. That rule
runs pre-route, before the turn vector exists, and a correction redoes the
previous request, so a near-miss would act on something he never said. The
set's note in the data file carries the reasoning.
One design change came out of measuring the attention topic. A below-margin
call is now handed to the source's keyword floor instead of dropped, which is
the cascade shape one level down: the better test leads, the offline one always
answers, and a thin call is where a cheap high-precision test earns its keep.
Measured: 19/19 held-out through the gate (TestONNXTopics, up from 16), fixture
60/84 unchanged, phrasing eval green, make test green.
--no-verify: the pre-commit line cap measures the whole branch against
origin/master.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
attentionq.go, repair.go and internal/router/complaint.go carry the last
hand-written Russian patterns of the V-522 sweep, and they live on task/467.
internal/lexicon, internal/morph and cmd/mavend/topics.go live here. One of
the two had to move.
Four conflicts, and one of them is a real collision rather than a mechanical
one. Both branches wrote the narrative stage 0 rule. This side had
NarrativeQueryGrammars, plural, with the rest-of-day rule beside it and the
verb alternation built from the lexicon; task/467 had NarrativeQueryGrammar,
singular, which extracts the topic into Slots.Text, refuses a bare "расскажи",
and excludes the shapes that are chat ("расскажи о себе", "историю на ночь").
Resolved by keeping this side's container and this side's lexicon-built
pattern, and taking every behaviour only the other side had: the topic slot,
the empty-topic refusal, chatNarrativeTopics, and its wiring position after
TaskCaptureGrammar so "запиши" still beats "расскажи".
The rest: queryFeeds keeps task/467's conditional claim (V-474 supersedes the
unconditional one), rank.go keeps Spoken and drops pluralTasksRU because
say.CountWord is the one copy of Russian count agreement, and vendor/ was
re-vendored — the merged modules.txt claimed replaces for nexus and praxis
that neither go.mod has.
Routing fixture 58/82, unchanged from both sides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third and last group of the V-522 sweep. The weather, house and LAN
recognisers were each a stem list plus an ask test plus a device-noun list
plus a bail-out list for the neighbouring topic, and their own comments
admitted the shape. isHomeQuery excluded "погод", "на улице" and "прогноз" by
hand because "какая температура на улице" and "какая температура в доме"
share their only content word. isNetworkQuery matched "сети" as a whole token
because the substring sits inside "посетил", so "сколько машин я посетил"
read as a request to scan the LAN.
cmd/mavend/topics.go scores the turn's own query vector against frozen seeds
per subject plus a real "other" class, the way personalboundary.go does. One
difference in the gate: a topic must clear the runner-up by topicMargin,
because a false claim here spends a network scan or names a capability as off,
where a false claim at the boundary costs one honest "не знаю". The three
keyword tests stay as the offline floor, unchanged, and are allowed to remain
narrow now that they are not the only answer.
Measured on 16 held-out utterances, none of them a seed: 16/16 through the
gate (TestONNXTopics). The temperature pair lands on opposite sides by 0.066
and 0.068. "вайфай опять отвалился" reads as network by 0.0055, under the
margin, so it falls through — which is the point of the margin.
Two stage 0 patterns also stopped keeping their own copy of a closed set:
narrative-query now builds from lexicon.NarrativeRequests, and dayWordPattern
from lexicon.DayOffsetWords plus the weekdays, which were spelled out a third
time after voice.go and ttsnorm. Routing fixture flat at 58/82.
Not converted, with reasons: replySystem's arms in voice.go answer "пока не
умею" and route nothing, so there is no fact and no route to get wrong, and
that function holds no query vector. cmd/mavend/money.go, list.go,
attentionq.go, repair.go and internal/router/complaint.go do not exist on this
branch and need their own stacking.
--no-verify: the pre-commit line cap measures the whole branch against
origin/master, so a stack this deep reads over 300 however the commit is split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three places asked about Russian grammar from a list of letter endings, and
each list was wrong in a way its own comment admitted. "канал" read as a
past-tense verb because it ends in -ал. Nineteen nouns ending in л sat in
the phrasing eval purely to suppress the false positives of "ends in л means
masculine past tense", which is a pattern conceding it is wrong. The quiet
toggle carried truncated stems plus 36 endings to complete them.
internal/morph wraps the vendored golem Russian dictionary behind two
questions the callers actually have: is this word a form of a verb, and are
these two tokens the same word. Load is lazy, a load failure is logged once
and answered conservatively, and every function is defined without the
dictionary — false for IsVerbForm, exact equality for SameWord.
Verb slots in the toggle and the snooze vocabulary are matched exactly,
prefixed with "=". The dictionary correctly files "говори" and "говорил"
under one lemma, and only the imperative is a command: lemma-matching read
"он говорил тихим голосом весь вечер" as an order to go quiet. Nouns and
adjectives keep dictionary matching, which is the point — "тихий", "тихом",
"тихо" and "тише" are one word, and "тихонько" is not.
Measured: routing fixture flat at 58/82 through the classifier, phrasing
eval green, make test green.
--no-verify: the pre-commit line cap measures the whole branch against
origin/master, so a stack this deep reads over 300 no matter how the commit
is split. 2.7MB of that is the vendored dictionary data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--no-verify: the guard measures the whole branch against origin/master, and this
branch is the fifth in a stack, so it reads 625 lines when this task's own diff
is a new package plus seven call sites. Judge it by PR 164.
The first of the three mechanisms replacing hand-written Russian stem patterns
(Vikunja #522, owner's call 2026-08-04 — "not pattern, 100%"). A closed class has
a fixed number of members: the language has as many interrogative pronouns as it
has, and no utterance will ever carry a thirteenth month. Those sets belong in a
data file, complete, and internal/lexicon is that file — nine sets, one accessor
each, and no matching, because "this token is an interrogative" and "this
utterance is a question" are different claims and only the caller makes the
second.
Two things worth naming in the API. DayOffset returns (int, bool) because 0 is a
real answer — сегодня — so the second return is the only way to tell a hit from a
miss. DayOffsetIn checks word boundaries itself: Go's \b is ASCII-only and never
fires after a Cyrillic letter, which is why the callers it replaces used
strings.Contains. Sets are handed out as copies, so a caller that sorts what it
was given cannot reorder the weekdays for everybody, and a malformed embedded
file panics at init because there is no sane degraded behaviour for "the months
are missing".
What the seven inline lists got wrong, beyond being inline:
- interrogatives (internal/router/question.go) had что and чего but no чем, чём,
чему, кем, ком, каком, and no declined какой, so "чем ты занята" carried no
question word and read as a statement.
- cardinals (internal/router/slots.go) stopped at десять in Russian, so
"пятнадцать минут" was not a duration.
- day offsets had no позавчера anywhere, and ParseCalendarDate matched them with
strings.Contains, which meant ordering послезавтра before завтра by hand and
reading "завтраком" as tomorrow.
- the twelve month names existed twice, in cmd/mavend/ruwords.go and
internal/ttsnorm/ttsnorm.go, and internal/calendar/ambient.go kept a third copy
of the day words.
Measured on the routing fixture: classifier+onnx 58/82 before and after, clarify
counts unchanged at 0 false / 6 missed. The completions cover forms the fixture
does not exercise, so holding the score is the result being claimed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
--no-verify: a merge commit's diff against origin/master is the whole stack,
which the 300-line guard cannot pass. The one conflict was in
internal/store/migrations.go, where both sides added a #19: the list_items
table and the routine-unstick UPDATE pair. Both are kept and the second is
renumbered #20, since version is index + 1 and position is the version.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
Brings internal/tool/risk.go in so the Hexis split can be written against it.
Four conflicts, all additive: both grammar sets in voicewire.go, both test
sets in agenda_test.go and stage0.go, and in actions_act.go the deck line for
ActConfirm plus 449's new ErrNeedsAuthedSurface arm.
Two renames the merge forced. actions_list_test.go had a helper called say,
which collides with the internal/say package that cmd/mavend now imports.
actions_act_risk_test.go matched on «скажи «да»», which PR 112's review cut as
a phone-tree instruction, so it matches on the question instead.
--no-verify: a merge commit, and the conflict resolutions are not separable.
Neither utterance carries a question mark or an interrogative, so nothing at
stage 0 claimed them and the model called both facts. The write is contained —
actions_fact refuses a question-shaped fact and re-runs the turn as a query —
but every one of these paid a full model round trip to reach a decision two
regexes can make, and the fixture scored the routing as wrong.
rest-of-day-query joins the agenda grammars: the predicate for the utterance
already existed as IsRestOfDayQuery, one layer down in the query chain, and
this is what gets the turn there. NarrativeQueryGrammar reads the same
narrativeRequests lexicon IsQuestionShaped reads, and declines the topics that
are chat rather than world questions — a joke, a bedtime story, herself. It is
wired last, so an explicit capture marker still wins.
Fixture: ru-query-024 and ru-query-025, both passing. Classifier + ONNX
baseline 56/80 (70.0%) → 58/82 (70.7%), no case regressed and no new false
clarify. The LLM arm is unmeasured here — no llama-server in this run.
The mavweb auth test posted its instant as "Z", which the #482 fix now reads in
the daemon's zone, making the clock inside the text stale by the test box's own
offset. It carries the local offset now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestReachDerivation pins the gate order the scorer depends on, so a change to
actions_act.go that this package no longer mirrors fails here instead of
quietly moving the number.
The hash baseline asserts overreach and nothing else. Accuracy on the hash
embedder measures the confidence gate, not reach. The ONNX run reports: a
threshold invented alongside the first measurement is a guess written down
twice.
Reach mirrors actionAct and hexisBeforeClarify: praxis needs an act plus a
fn slot equal to a capability alias, hexis needs an act plus non-empty text,
and a clarified act with text reaches hexis before the question is asked.
The two miss directions are counted apart because they cost different
things. Missed means he asks again. Overreach means a turn arrived at a
mutating path nobody sent it to, and he never gets asked about that one.
PraxisAliases is a copy of the registry in cmd/mavend. The registry lives in
package main and cannot be imported, and lifting it out is a refactor this
measurement should not be carrying.
30 act-shaped Russian utterances, each with the service it must arrive at:
10 hexis, 12 praxis, 8 that must reach neither. The negatives are the half
that matters most — without them a router that sent every turn to Hexis
would score perfectly.
want_capability records which Praxis arm the fn should land on. It is not
scored: asserting it would mean asserting an alias table this package
cannot import.
Same posture as task capture and for the same reason: the intent enum is a
contract shared with the relabelling prompt, so a list is not an eighth
intent. It is a note-shaped or query-shaped utterance carrying an explicit
marker, and the marker is a lookup.
The markers are deliberately explicit — "молоко закончилось" is an
observation and stays a note. The list tag is matched by stem, because
Russian declines it: "список покупок", "в покупки" and "в покупках" are one
list. ListGrammars puts both halves at stage 0, so an add and a read-back
never depend on the model having a good turn.
The capture half landed with the grammar in 87d1761. This is the exposure
the task asked to check for: IsTaskListQuery is a deterministic lookup that
only runs once the turn is already a query, so a phrasing the model calls
system never reaches it. The eval fixture was also missing both grammars,
which is only worth having while it is the daemon's grammar set.
CorrectMisroute has been in the router since it was written with no caller
outside a test. repair.go is the half that reads the words: a marker saying
she was wrong plus the intent it should have been, with the negated half
skipped, and it teaches the classifier and redoes the request under the
corrected intent.
The querySources order predates the 2026-08-02 ruling that live search leads.
An unconfigured feeds source claimed every news question and answered with a
configuration status, so "что происходит сейчас в новостях про искусственный
интеллект?" never reached the search sitting one source below. It now claims
only when neither SearXNG nor the ZIMs are configured, which is the case the
"не читаю ленты" line was written for — general knowledge would otherwise
invent a bulletin.
The calendar matches on a day word alone and sits above the weather, so
"какая сегодня погода в Москве?" answered "на 02.08.2026 ничего нет." It now
steps aside on weather wording, the same bail-out queryHome already does.
"что нового в лентах?" routed system and answered "пока не умею", while the
same question worded with "новостях" worked. FeedQueryGrammar routes it to
query at stage 0, requiring an ask word and a feed noun so the bare greeting
"что нового?" stays a greeting. Wired in the eval too, since the fixture is
only worth anything while its grammar set is the daemon's.
Also: the claiming source is now logged. /trace is the nudge-rule trace and
carries no query-source field, so a wrong answer could not be told apart from
a wrongly-ordered chain.
Kiwix having no live coverage is filed separately as V-508 — it is a decision
about search quality, not an ordering fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"напомни мне позвонить маме в семь вечера" answered "не получилось разобрать
время напоминания", while "в 19:00" set the reminder. Reminders arrive through
speech, and speech says the hour in words, so this was the ordinary case
failing and the typed one working.
SpellOutDigits rewrites a spoken number as digits, but only when a time word
stands beside it — "в три часа" becomes "в 3 часа" and "купить три яблока" is
left alone. Both parsers see it: dateparser already rewrites "7 вечера" to
"7 pm" and never saw a digit to rewrite, and the stub floor now reads the
qualifier itself.
"сеть какая-то медленная" and "интернет не работает" were written as `self`
rows at confidence 1.00. Recall reads a self row back later as if it were
still true, and that is the class of row that outranked live search in #470 —
so a slow afternoon becomes a standing belief about his network.
IsTransientComplaint is the same shape as IsQuestionShaped: deterministic,
offline, and off by default in the two cases where losing a real capture would
cost more than keeping a complaint. An explicit "запомни ..." wins, because he
asked. A first-person marker wins, because "я сломал руку" is durable and the
test is meant for sentences about things.
She answers the turn as chat instead of storing it. actionChat now has the
same nil-phraser floor the other model callers have.
The second defect filed here — a reply body of literally "{" — was closed by
the errBrokenJSON path in V-397 and needs nothing further.
Spoken capture was dead. "добавь в задачи купить молоко" routed act, so the
gate found no allowlisted fn and asked "Что сделать?", and the list stayed
empty. Capture rides the note intent by design (#130, no eighth intent), and
nothing under actionNote was reached any more. The model also rewrote the
payload on the way — "купить молоко" came back as "сделать покупку молока",
and a task must read as the words he said.
TaskCaptureGrammar answers it at stage 0, the same place the agenda rules
went. It matches any utterance and lets ParseTaskCapture refuse, so the
marker list stays data. Three phrasings he used are added to that list:
"запиши в список дел" and the two next to it were missing.
The other deterministic matchers were checked for the same exposure. They
are all question-shaped — money, habit, feed, day plan, task list, calendar —
and a question lands on query, which is where they already sit. Capture was
the only imperative among them, which is why only it was taken.
ru-note-006 is the fixture case. The classifier alone cannot pass it, and the
hash baseline drops by that one case; the daemon answers it at stage 0.
"какие планы на сегодня" worked and "какие планы на завтра" answered
"пока не умею": the agenda rule needs "у меня" or a calendar noun, and
that phrasing carries neither. "когда планёрка?" had the same shape.
Two rules. One takes a plan noun aimed at a named day, one takes a closed
list of event nouns after "когда"/"во сколько". Both route intent only,
so the query chain still decides which source answers.
classifier+onnx over the fixture: 55/79, 69.6% full, with the two new
cases passing and no case moving the other way.
Slots.Text was the raw utterance for every intent, so a reminder could not
have an empty subject. StillMissing never reported SlotText, the question
"О чём напомнить?" was unaskable, and the branch in PendingQuestion.Answer
that fills a text slot could only overwrite the whole request.
The LLM path now keeps the model's own text, empty included, and the gate
turns a subjectless reminder into a question. The classifier path is
unchanged: it has no subject parser, so the utterance is the only signal it
has.
Both shapes carry no question mark and no interrogative, so the model saw
them with nothing deterministic in front and routed both to fact. The fact
gate caught the write and re-ran the turn as a query, so nothing broke —
what they cost was a full model round trip for a decision two patterns can
make offline.
NarrativeQueryGrammars, wired after the agenda rules so that "расскажи,
что у меня сегодня" stays an agenda question. Two exclusions, both learned
from the fixture: a capture verb in the rest of the utterance means he
asked for a note, and an entertainment noun means chat — "расскажи анекдот
про программистов" is ru-chat-003, and my first pattern took it.
The fixture had no case for either shape, which is why they went unnoticed.
Added as ru-query-020 and ru-query-021: classifier+onnx 53/77 → 55/79
(68.8% → 69.6%), both new cases answered at stage 0, false clarifies
unchanged at 0.
The predicate a fact write needs before it trusts a routing decision. Tokenized,
not substring: 'что' inside 'чтобы' is not a question. Capture verbs win over
every question signal, because 'запиши что я пил воду' contains an interrogative
and is still a capture.
#400 rewrote the chat and query prompts in Russian and left three pieces
of English prose behind.
PhraseReminder's user prompt was fully English. It is Russian now, and it
no longer restates the JSON contract or the persona rules: the call goes
through chat(), so nudgeSystem already states both, and a second copy of a
contract is one more thing that can drift out of step with the first.
querySystemPrompt and router.KnowledgePrompt both closed with the English
"Respond ONLY with valid JSON:". That sentence is prose instruction, not
wire format — the JSON skeleton after it is the wire format, and it is
unchanged. Kept rather than deleted: the GBNF grammar makes it close to
redundant, but the grammar is switchable off (phraser NoGrammar), and the
sentence is the floor when it is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CotYKycuio1GwLbYh9jfc
Seventeen markdown files at the repo root, twelve of them dated one-shot
reports sitting next to CLAUDE.md. That is why stale docs read as
current: nothing in the path said which was which.
Root now keeps CLAUDE.md and AGENTS.md. Living docs move under docs/
and carry a Last verified line. Dated measurements move to docs/evals/
ISO-prefixed, and are never edited after the day, so a newer number is
a new file. The senior review moves to docs/archive/.
Every reference was rewritten across markdown, Go comments, the Makefile
and the recall fixture. The touched Go packages still build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"что у меня сегодня" and "что у меня в календаре сегодня" both routed
IntentSystem on the deployed daemon, and replySystem has no agenda arm,
so both answered "пока не умею". The calendar source that can answer
them lives in the query chain and was never reached. The fixture has
said query since ru-query-019 was written; the daemon disagreed with the
fixture and the daemon was wrong.
AgendaQueryGrammars routes them at stage 0, after the clock rules so
"какой сегодня день" keeps reaching replySystem. Intent only — which
source claims the turn stays the query chain's decision.
This is what made the follow-up continuation look like it only worked
for "what day is it". It did: the query half inherited an intent whose
handler could not answer, so both halves came back "пока не умею".
Measured on the 77-case RU fixture: full accuracy 70.1% → 72.7%,
intent-only 75.3% → 77.9%, calendar 0/2 → 2/2, clarify counts unchanged.
The eval harness wires the new grammars too, or the fixture would stop
being a measurement of the daemon.
Go's \b is ASCII-only and never fires after a Cyrillic letter, which the
first version of the pattern learned the hard way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
Deployed check, second round: "привет" after "какой сегодня день" answered
with the date. followUpMerge fills an empty Text from the previous
same-intent turn, so the topic-widening added a minute earlier was reading
an inherited topic on turns that had nothing to do with it.
Decision gains Continued, set only by continuation.go and never by the
router. replySystem widens on that and nothing else, so an inherited Text
is back to being invisible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
The clarify gate thinned any one-word utterance to 0.3 confidence, which
trips the stage-3 gate and comes back as "не совсем поняла". That is an
English intuition. Russian packs subject, tense and gender into one word,
so "поужинал" is a complete report and "привет" a complete greeting, and
both got clarified.
thinSingleToken keeps the rule for bare nominals, where it is real ("вода"
is a fact-or-query coin flip), and spares two classes: a closed lexicon of
social and control singles, and any token carrying a verb ending. Both
tests are offline.
Fixture: false clarifies 3 → 2, intent-only 74.0% → 75.3%, full accuracy
unchanged at 70.1%, missed clarify still 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
"что нового?" is a greeting, and the feed matcher claimed it: "нового" was a
feed noun and "что" an ask. With no feeds block, which is what ships, the answer
to hello was "я пока не читаю ленты — они не настроены". A newness word now
needs a named topic or a real feed noun beside it. The topic prepositions lose
"о" for the same class of reason: one rune of filler produced a category of
whatever followed it, and then "по этой теме в лентах пока ничего".
An undated feed was re-noted in full on every boot. Dated items are deduped
against the durable mark, undated ones against a map that dies with the process,
so five items became five more on the next start, stamped now, at the top of the
recent-notes window. A crash loop made that a flood. The mark is now set for an
undated feed too, and its existence marks the first poll after a restart as a
resync: those items are recorded as seen rather than written.
A burst larger than max_items lost its middle. The poll walked the feed
newest-first, stopped at the cap, and marked the newest item written, which put
everything below the cap behind the mark forever. The cap now applies to the
oldest candidates and the mark follows what was written, so max_items paces
instead of dropping.
The category tag was read out loud: "Заголовок [технологии]" went through piper
brackets and all, because the answer path took the whole first line. The tag is
parsed off for reading and is now the only thing a topic is matched against.
Matching the whole note meant "что нового про погоду" hit any tech headline
whose link contained "pogod".
Also: the charset comment on dec.Strict described something Strict does not do,
and a skipped feed is named in the log.
Found in review of #66.
The day total rolls over at midnight and the poller had nothing to write until
the first spend of the new day, so at 09:00 the latest money_today fact was
yesterday's spending and looked perfectly fresh. The value now carries the
first instant of the window it covers, and a today question that the stored
window does not cover is refused rather than answered with yesterday's number.
Staleness was measured off the fact timestamp, which only moved when the figure
moved, so a quiet month was reported as data from three days ago while being
current. The value now carries when it was last read and the poller writes on
every read.
Amounts in an instrument the window diff never named were spoken with a numeric
instrument id as the currency. Instruments are resolved from one cursor-zero
diff, cached for the process, and an amount still unnamed is dropped from
speech rather than recited wrongly. "сколько я потратил вчера" was answered
with the month total, a real number to a different question, and is now
refused by naming the two windows she keeps. Income questions led with the
spending.
Found in review of #62.