V-568. queryEmbed claimed the turn on an EmbedQuery error and answered
QueryFailAnswer from position 12 of querySources, above memory, notes, the
personal boundary, search, Kiwix and general knowledge. So one failing embed
call made every question below it answer "не смогла ответить", including the
ones search and Kiwix would have answered without the embedder at all.
The notes source had the same bug one position lower, on a QueryNotes error.
Both now log once and pass. Both also gained an empty-vector guard, because
scores off a nil vector are not a "there is nothing" answer.
The distinction the audit used: a source that looked and found nothing may
claim, a source that could not look must pass. day-plan, habits, feeds,
calendar, weather, home, network and web keep claiming, because each already
matched a question about his own data and nothing below can answer it.
Answering a personal question with a paragraph about the world is V-474 and
V-479.
V-565. internal/claim holds Claim{Claimant, Intent, Filled, Consumed,
Unexplained, Band, Veto} and imports nothing from Maven, so the dialogue to
router edge stays impossible. internal/router/claim.go builds one from a
Decision. Additive: nothing in Route calls it and Decision.Confidence is
untouched.
Measured first, on the 91-case fixture. Stage 0 emits 1.0 always and is right
20/20. The classifier cosine spans 0.859 to 0.942 and is right 62% of the
time, with 62% correct below its median and 62% above, so the number carries
no signal about correctness. The top1 to top2 margin is worse: p50 0.009, 68
of 71 cases under 0.02. A calibrated float is not cheaply available from the
classifier, which is what the task's ledger asked to be checked.
So four ordinal bands, highest first: anchored, structural, nearest, vetoed,
with unknown at the bottom so a builder that forgot cannot outrank a measured
claim. Anchored against nearest is 100% against 62% on the same utterances.
Nearest is one band and not a scale because the cosine is flat.
Coverage decides before the band does. That is what fixes Rome: the pending
claimant ate the question while explaining one token of it.
No fixture number moved. TestONNXBaseline is 64/91.
queryEmbed claimed the turn on a failed EmbedQuery and returned
QueryFailAnswer. It sits above memory, notes, the personal boundary,
search, Kiwix, the named page and general knowledge, so one ONNX error
answered every question below it with "не получилось найти ответ",
including the ones search and Kiwix answer without an embedder at all.
It now logs and passes, the shape turnVector already had. The two recall
sources below pass on an empty vector rather than searching on one, and
queryNotes passes on a store error too: a source that could not look is
not a source that looked and found nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
router.ClaimOf maps a Decision onto the common unit. Stage 0 is anchored,
the LLM path is structural, the classifier is nearest, and anything with a
structural hole is vetoed whoever produced it.
The veto recovers the reason gateLLMDecision throws away. Folding three
named holes into llmThinConfidence leaves 0.3, which says something was
wrong and never which thing, so the same conditions are read here as
sentences a trace can print.
Nothing in Route calls this. Decision.Confidence keeps its float and keeps
working, because r.threshold and gateLLMDecision read it and the classifier
is the failure floor. TestClaimOfLeavesTheDecisionAlone asserts that.
TestONNXBaseline is unchanged at 64/91.
TestCoverageBeatsBand is the V-558 failure as an ordering assertion. The
weather claim explains the whole utterance and the pending reminder explains
one token of it, so coverage settles it before the band is consulted.
TestBandOrder asserts the order rather than trusting the iota, and pins
BandUnknown at the bottom: a builder that forgot to set a band is a bug and
must not outrank a measured claim.
internal/claim holds the common unit: who wants the turn, the intent, the
slots it would fill, the tokens it explains, the tokens it declines, and why
it should not win. Specificity reads Consumed against Unexplained and
negative constraint reads Veto, so neither needs a float.
Four ordinal bands where a number is unavoidable, argued from measurement in
docs/plans/19-dialogue-arbitration.md. Anchored, structural, nearest,
vetoed. Nearest is one band and not a scale because the classifier's cosine
measured flat against correctness.
MoreSpecificThan puts coverage first and the band second. That is the fix
for the failure V-558 opened with: a pending reminder ate the Rome weather
question while explaining none of it.
The package imports nothing from the rest of Maven. internal/dialogue must
not import internal/router, so Intent is a plain string.
V-564. One decision.Record per turn: the utterance, the winner, and a Claim
per claimant carrying its stage, name, the intent it would have made the turn,
the score it reported, the outcome and the reason. HasScore is separate from
the score so a real 0.0 is not read as no score. Outcomes are won, declined,
lost_on_order, lost_on_score, thinned, merged, never_asked.
Every stage declares its roster up front, so Finish names everyone who never
reported. NEVER ASKED is explicit rather than an absence, which is the fact
the hardcoded ordering hides.
Covered: the seven pre-route resolvers, eleven stage 0 grammar sets, the LLM
router and the classifier with which arm of gateLLMDecision thinned a route,
the classifier runners-up, the follow-up merge, 27 query sources, and a
terminal action-handler or clarify-ask claim.
On by default, no flag. It rides the context like querysource.go and is
installed in runTurn, so mic, telegram and web leave the same trail. Storage
is a 25-turn in-memory ring: no write on the answer path, no migration, and
none of his words outlive the diagnosis. Readable on /trace.
TestRecordingDoesNotChangeTheReply answers the same utterances with and
without the ring.
Two reporting tests over the 91-case RU fixture, no ratchet: a ratchet here
would freeze a number nobody has decided to hold.
TestStage0Contention runs the 21 grammars one at a time instead of stopping
at the first match. One case of 91 draws two, ru-query-019, where
calendar-query beats agenda-query by list position alone.
TestONNXClaimConfidenceDistribution buckets the reported confidence by the
layer that produced it. Stage 0 is 20/20 at a hardcoded 1.0. The classifier
scores 62% below its median and 62% above, across a cosine range of 0.859
to 0.942, with a top-two margin of p50 0.009. The float is not a confidence.
newBaselineClassifier and baselineGrammars split out of newBaselineRouter so
the measurement runs the same rules the daemon runs. TestONNXBaseline is
unchanged at 64/91.
Both tables answer the same question, who won and who lost, one about nudges
and the other about utterances, so they share a page rather than splitting the
nav. A turn is one collapsible row; never_asked is coloured like a block,
because it usually is one. A read failure is logged and the rule trace above it
still renders: a daemon too old to know the method is the ordinary case during
a rolling deploy.
Same shape as TickTrace and RecentEvents: a bounded daemon ring, so the store
adapter refuses rather than pretending a table exists. No voice wiring means an
empty list and not an error, because a box with no voice path has had no turns
to arbitrate.
The two claimant sets that live in the daemon are where the arbitration is
least visible: both are a hardcoded order of functions that each answer 'is
this mine?' alone. The ladder declares its roster up front, so a rung that
never ran is named rather than omitted, and the query chain does the same for
the sources below the one that claimed.
Recording is installed in runTurn and not in the IPC entry point, so the mic,
telegram and the web leave the same trail. A record only the web produced would
be missing exactly the turns that are hardest to reproduce.
Stage 0 records every grammar it reached, keeping a pattern that never matched
apart from a Build that refused the content, and names the ones after the
winner as never asked. The routing arm records the classifier's runners-up and
which arm of gateLLMDecision cut the confidence, because thinned alone is not
enough to act on.
Arbitration between the claimants on the utterance stream is order, hardcoded
in three places, and a log that names the winner cannot explain a loss. The new
package holds one record per turn: who claimed, what it would have made the
turn, the score it reported, and why the rest did not get it. Being explicit
that a claimant was never asked is the point: that silence is what the
hardcoded ordering hides.
The record rides the context, the seam querysource.go already uses, so no claim
site can change a route and a context with no record costs nothing. The ring is
memory and bounded: a turn record is read minutes later or never, and his words
do not belong in a table that outlives the diagnosis.
V-563. cmd/mavend/dialogue_contract_test.go holds twelve whole multi-turn
traces. Each turn asserts the reply, what is parked afterwards including the
attempt count, and the end state: reminders with payload and fire time, fact
keys, note count, task texts.
Six traces pass today. Six carry the correct expectation and skip, naming the
task that makes them green: the owner's transcript and its parseable twin
(V-561), cancel and a correction under a parked question (V-560), a whole
reminder still being asked about and a short correction (V-562).
MAVEN_DIALOGUE_NO_SKIP=1 runs the skipped rows, so a fixer sees their row turn
green and a stale skip is caught.
Offline: hash embedder, no llama-server, no ONNX. Failures print a claimant
trace derived from the daemon's log lines, so a wrong claimant reads
differently from wrong copy.
Each carries the correct expectation and is skipped with the task that
will unskip it, because a weakened expectation would pin the bug as the
contract. MAVEN_DIALOGUE_NO_SKIP=1 runs them.
V-561: the owner's transcript, and the same shape in words the offline
date parser reads — a side query drops the parked question instead of
suspending it, so Rome is never answered and the reminder is never set.
V-560: a cancel is scored as a failed answer and spends a retry; clarify
pre-empts the repair marker, so no correction can be spoken mid-flow.
V-562: a stage-0 reminder never meets the extractor, so a reminder said
whole with its hour in it is still asked about; and finishClarified goes
straight to applyAction, so a repaired decision that lands short answers
with a parse error instead of asking.
Six whole traces through the real cascade with no model: a reminder and a
fact each completed over two turns, an answer that arrives past the TTL,
three unclear answers and the give-up line, a correction of the previous
turn, and an abandoned flow. Each asserts the reply, what is parked after
every turn, and the end state of the store.
Measures what each claimant on an utterance reports across the 91-case RU
fixture, then argues an ordinal band set from that distribution.
The classifier's cosine is flat against correctness: 62% correct below its
median and 62% above, over a spread only 0.083 wide, with every case above
the 0.55 gate. Its top-two margin is p50 0.009 and never reaches 0.03. So a
calibrated float is not cheaply available and the ledger's assumption holds.
Stage 0 is 20/20 on the cases it claims and asserts 1.0 for all of them. The
LLM router emits two values, and the lower one is a self-veto with a reason
flattened into a number.
V-559. internal/dialogue gains PendingAction: capability, slots, missing
slots, TTL and attempt cap, with CapabilityFor as the one intent to
capability map. PendingQuestion derives its action rather than storing a
second copy, so the TTL and attempt rules have one implementation.
The clarify store now holds a bounded stack, MaxStackDepth 2. Behaviour is
identical: Put replaces the top, nothing calls Push, so the daemon runs at
depth one. Push returns what the bound evicted, so nothing dies silently.
Groundwork for V-560 and V-561.
Push/peek/pop including that a peek does not consume and that the flow
under a popped entry survives; that a popped entry stays gone; that a push
past MaxStackDepth returns the evicted entry rather than dropping it
silently; that Put keeps the depth at one; that an expired top takes the
stack with it and is reported once by TakeExpired; and that two dialogue
ids do not read each other's stack.
One parked question per dialogue id meant a side query could only destroy
the flow it interrupted. The store now keeps a stack per id, newest last,
with Push, Peek, Pop, Depth and Delete as drop-all. MaxStackDepth is 2:
one flow plus the thing he interrupted it with, because spoken
conversation does not nest deeper, and because every level she keeps is a
level she has to be able to speak when it dies.
Behaviour is unchanged. Put still replaces the top rather than growing the
stack — a re-ask is another question about the same action — and nothing
calls Push yet, so the daemon runs at depth one exactly as before. Get is
Peek under the name the callers already use. An expired top takes the
stack with it and TakeExpired reports it, so no parked action dies without
a word; Push returns the entry the depth bound forced out for the same
reason.
PendingQuestion.IsExpired and CanAsk now answer through PendingAction, so
the TTL and attempt-cap rules have one copy and the widening cannot drift.
A parked clarify said what she heard (an intent) and not what she was
about to do, so the resolver had to infer the action from conversational
history instead of reading it off an object. PendingAction names the
capability being assembled in the ecosystem's dotted form
(reminder.create, fact.write, act.run), the slots it has, the slots it
still wants, when it was asked, attempts and TTL.
Gaps() computes the missing slots from the slots rather than trusting
Missing, because Missing is what she asked and the slots are what she
got. CapabilityFor maps every dialogue.Intent, so the mapping lives here
and dialogue still does not import router (the cycle rule).
Nothing reads it yet: this is the widening V-560 to V-562 build on.
The clarify path was gated on dec.Clarify, so a turn the cascade routed
confidently but incompletely skipped it. "напомни позвонить" reached applyAction,
failed on the missing time and parked nothing, and the "в семь вечера" that
followed was routed as a world question and web-searched.
The gate now also fires when missingFor names a required slot. A bare capture
verb gets a stage-0 rule of its own: it was reaching the resident model as chat,
which answered by agreeing to a wording change nobody asked for.
askClarify parked "Что сделать?" whatever was on the other end. With an empty
allowlist that question has no answer: she asks, fails, asks again and gives up,
three turns spent on a request she could have declined in the first one.
Empty allowlist now names the gap and parks nothing. A non-empty one still asks,
and names what she can run, capped at six, so the question is answerable.
Three durable stores said no client machine existed. That was written
when the workstation was only a model host. It is where he sits most of
the day and it has the microphone.
The verdict is unchanged and so is the seam. What changes is the size of
the remaining work: deploying two daemons and asking mavend to listen on
TCP, not acquiring hardware. Note that deploying them does not by itself
prove a wake word — mavwaked gates on energy and has no keyword model
(V-487).
Measured on the box: "глаголы в прошедшем времени с окончанием -ла",
copied from the query prompt where it fixes her gender, was read by the
resident model as an instruction to use the past tense throughout. She
answered "я вела заметки" and "если ты разрешил, я управляла домом",
which makes a live capability sound finished.
The gender rule stays, without the example.
Two defects found probing the new source on the box.
"кто ты" was answered from one of his notes. The self source sat below
memory and notes, which match by proximity and have no idea the subject
is her. It belongs above all three: a question about her has no answer
in his data either.
And PhraseQuery opens every answer with "вот что я нашла: ", which is
deliberate — it marks the answer as a lookup. Her own description is the
one subject she did not look up, so this is PhraseSelf instead, same
read-only discipline and its own opener. The Stub reads the description
out as it stands, which needs no fallback: it is already her voice.
"что ты умеешь" reached the personal boundary, which claimed it as his
and said "не знаю — не нашла у тебя такой записи" about her own
description. Letting it past would be no better: SearXNG answers about
somebody else's assistant.
A self query source above the boundary, reading one frozen description.
It is NOT a note — notes are his, and a note about her would come back
for "что я записал", would be fed to the digestion worker as something
he said, and would be recalled by proximity for questions that are not
about her.
The description names only what this box does. Everything that depends
on config — the house, the LAN, the feeds, the list, weather, telegram —
is named as depending on what he allowed, and a test pins that split:
inventing a capability here is the same defect as inventing a fact.
topicSelf is scored like every other topic, with a narrow keyword floor
for the no-embedder case. "что ты умеешь" moved off topicOther, where it
had been sitting so an attention question had something to lose to — a
phrasing on two sides never clears the margin. TestONNXTopics 38/38 ->
43/43 on held-out utterances.
Found verifying the three fixes on the box: "кто изобрёл телефон" ran a
LAN scan and answered "нашла 3 устройства". The network seed set opens
with "кто в сети сейчас" and names devices throughout, so a "кто ..."
question about any device noun landed there.
Three topicOther seeds, same shape as the V-553 fix. TestONNXTopics
34/34 -> 38/38 on held-out utterances, and a real scan is still a scan.
Prior turns were joined with newlines and nothing else, so the model got
four unlabelled lines and no way to tell which one was the question. It
answered an earlier one: asked "как дела" after a question about the
telephone, she carried on about the telephone. Four turns live for
fifteen minutes, so the line she answered was often minutes old.
One user message still, because the template constraint that forced the
flattening is real. The turns are labelled as his own earlier words and
the current utterance is named as the one to answer. With no history
the message is the utterance alone, unchanged.
"расскажи про Байкал" was refused as his by 0.0052. Every world seed
opened with an interrogative, so a world question phrased as an order
landed nearer "я тебе рассказывал об этом?" — the same verb about his
own words. Four narrative seeds on the world side.
TestONNXPersonalBoundary 25/25 -> 29/29 on held-out utterances, and the
control "я рассказывал тебе про байкал?" is still his. TestONNXTopics
unchanged at 34/34.
A parked question consumed whatever came next. One act she could not
fulfil ate three turns: "выключи свет в спальне" asked "Что сделать?",
and "кто изобрёл телефон" was scored as an answer to it, then "как
дела" after that. Nothing tested whether the words could be an answer.
The test is two offline token checks that already existed for other
callers: a question shape, or a capture verb. It fires only where the
answer filled nothing, so an answer that closes the gap still lands
whatever shape it has, and the retry budget is untouched — the count
was never the problem.
The topic seeds let "какой сегодня праздник" and "что интересного
произошло сегодня в мире" past the weather source, and the personal
boundary refused them one source further down: "не знаю — не нашла у
тебя такой записи" about a public holiday.
Same defect, same mechanism, one layer lower. "что у меня сегодня" is a
personal seed and worldSeeds had nothing in that frame. Two seeds fix it.
TestONNXPersonalBoundary 22/22 -> 25/25, nothing regressed.
Two recognisers claimed world questions naming a day, both by the same
mechanism and neither by its keyword floor.
topics: weather was the only topic whose seeds carry a day word, four of
eight. So every "какой сегодня X" landed nearest it. "какой сегодня
курс доллара" cleared the margin by 0.0220 and "какой сегодня
праздник" by 0.0398, against 0.0883 for a real weather question, and the
gate asked "для какого города?" about the dollar.
The margin was not the knob: 0.0398 is not a coin flip, and raising the
bar far enough would take real weather with it. topicOther was missing
the negative class. Six seeds, four naming a day and two carrying the
"какой сегодня X" frame itself — a frame both topics use has to sit on
both sides, or the side that owns it wins every noun it has never seen.
personal boundary: the same shape one layer down. "что у меня сегодня"
and "когда моя встреча" put "when does a thing happen" on the
personal side and no world seed answered it, so "во сколько закат
сегодня" was refused as his. Three world seeds, each carrying сегодня,
which is the half of the frame that does the pulling — without it they
caught nothing.
Measured, both opt-in against the ONNX embedder homesrv runs:
TestONNXTopics 27/27 -> 34/34 (7 new cases, none regressed)
TestONNXPersonalBoundary 19/19 -> 22/22 (3 new cases, none regressed)
The control matters as much as the fix: "во сколько у меня встреча" is
the same frame about something that IS his, and it holds at +0.0842,
unchanged from before the seeds moved.