Compare commits

...

27 Commits

Author SHA1 Message Date
claude 00f9239ef9 Record the model arm, and the stage 0 trade it exposed (V-660)
Numbers and the argument in docs/evals/2026-08-08-destination-model-arm.md,
pointer and the short version in CLAUDE.md. The finding worth carrying is
not the 72.7%: it is that stage 0's silence on the possessive agenda rules
used to be free and now costs four destination points, because there is
finally something downstream that would have named the calendar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 18:23:22 +04:00
claude 3513e508b7 Give the router prompt a destination to write (V-660)
V-659 measured the destination at 12/33 on the classifier cascade and named
the gap: recall 0/15, because nothing anywhere names it. The model could not
help, for a structural reason rather than a capability one. Nothing in
routeSystem mentioned a Source and routeGrammar could not emit one, so there
was no string for it to write. Same shape as the Praxis reach V-517
measured at 0/12.

routeGrammar grows a source rule, closed over router.Sources plus the empty
floor. A grammar cannot emit a destination that does not exist, which is the
guarantee V-546 wants from a softmax and gets here for free. The prompt
lists the twelve in Russian, one line each, and says plainly that "" is a
normal answer to give often: two sources that can both answer means the
chain walks, and guessing is the failure mode this whole field exists to
stop.

The read-back goes through ValidSource and runs on IntentQuery alone. The
grammar already bounds the enum, but it is a request to a server that may be
running another build, and only a query reaches queryWalk.

Measured against gemma-4-12b on the workstation, same fixture, cascade with
a hash fallback: destination 24/33 (72.7%) against the classifier's 12/33,
and intent 81/96 (84.4%) which is where it already was. Recall is the whole
move, 0/15 to 14/15. The model alone scores 26/33.

Four cases the cascade loses and llm-only wins are calendar. The possessive
agenda rules claim them at stage 0 and deliberately name nothing, because
"что у меня в списке покупок" matches the same rule and naming the calendar
would take the list source off the turn. So stage 0's caution now costs four
destination points it did not cost before. That is a real trade and it wants
its own argument, not a quiet edit here.

The resident Qwen3-1.7B is unmeasured: it binds --port 0 inside the
container and no host process can reach it.

llm/check_prompt_parity.py in the training workspace compares its copy of
routeSystem to this one and will fail until that copy gets the same edit.
V-362 covers the catch-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 18:22:00 +04:00
claude c15c2b7bd2 Record the destination number and two stuck measurements (V-659)
CLAUDE.md said the destination had no fixture and no accuracy number. It
has both now: intent 73/96 and destination 12/33 on the classifier cascade,
with the per-destination split, the floor cases and the grammar drift the
labelling turned up. Anyone adding a grammar now reads that baselineGrammars
mirrors buildRouter and drifts silently when it does not.

docs/evals/2026-08-08-massive-warm-start.md was written on the V-655 branch
and parked in .task/, which git excludes, so it was one `task start` away
from being lost. It is a dated measurement and it belongs under docs/evals
whatever branch produced it. Its "destination has no fixture at all" line is
now a pointer to the file beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 18:07:41 +04:00
claude b6eaa704a2 Label the destination on 33 fixture cases (V-659)
Twenty-eight existing query cases get a want_source and five new ones
arrive with theirs. Every label is the destination that SHOULD claim the
turn, which on the five new cases is not the one that did: they were
observed failing on the box on 2026-08-07, so the fixture fails on the day
it is written.

Seven cases assert the SourceUnknown floor, and six of those are homelab
operations. They cluster because SourceRecall, SourceNetwork and
SourceAttention overlap on every question about the box: mavpoll writes its
netdata and uptime-kuma observations into the fact store recall reads.
Naming one destination there takes the other two off a turn that needs
them. That is a finding about the enum, not a gap in the labelling.

The fixture's grammar mirror had drifted. WorldQueryGrammars went into
buildRouter with V-655 and never into baselineGrammars, so the fixture was
scoring a grammar set the daemon does not run — the exact thing the comment
above that function forbids. Adding it moved the destination number 9/33 to
12/33 and moved nothing else.

Measured classifier+onnx: intent 73/96 (76.0%), was 69/91 (75.8%). Four of
the five new cases pass and no existing case moved. Destination 12/33
(36.4%), and the split is the point. World is 5/5, because a stage 0 rule
names it. Calendar is 2/6, because the possessive agenda rules deliberately
do not. Recall is 0/15, because nothing anywhere names it yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 18:05:49 +04:00
claude 2597a7b34a Score the destination apart from the intent (V-659)
The fixture measured the first half of a route and stopped. V-655 split a
routing decision in two, and the second half arrived with no fixture, so
Decision.Source had no accuracy number at all.

want_source is a pointer because the destination has three states and a
bare string has two. Absent is every intent but query, which never reaches
queryWalk. Present and empty is the SourceUnknown contract: name nothing
and let the daemon walk the chain, which is right whenever two destinations
can both answer and the utterance does not choose. Present and named is a
destination the route must produce.

A destination miss does not fail the case. It goes in SourceReason, never
in Reasons, so Accuracy and IntentAccuracy stay the numbers they were and
69/91 still means what it meant. SourceAccuracy is the second number, over
the labelled cases only, because a percentage of the whole fixture would be
a percentage of turns that never ask a query source.

A clarified or mis-routed case still counts in the denominator. It named no
destination and that is a miss, not a case to skip, or the denominator drops
every turn the route already lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 18:00:37 +04:00
claude 7203cd56fd Record the second half of a route in CLAUDE.md (V-655)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:04:01 +04:00
claude ab3e818bb9 A named destination silences the guessers and moves nobody (V-655)
querySources splits in two once you look at which sources over-claimed during
the week of 2026-08-07. The clean ones perform a lookup and can come back
empty: fact-by-key, tasks, list, money, calendar, notes. The dirty ones decide
by cosine against frozen seeds and then answer whatever they claimed, because
they have no lookup that could miss. Weather has no local table at all, which
is why "что такое TCP?" became "для какого города?".

So each source now carries its destination and whether it guesses, and
queryWalk takes the guessers that were not named OUT of the chain. It removes
and never reorders, which is the whole safety argument: the table's order is
load-bearing, every comment on it argues a reason between two sources, and
above all it carries "his data first, then the world". Naming SourceWorld does
not send the turn outside. It stops weather claiming a protocol on the way
past. His notes, his facts and the boundary in front of them still run first,
so a wrong destination costs nothing but the guess it prevented.

The skipped sources are recorded as never-asked with the reason, so /trace
shows a narrowed walk rather than a chain that silently shrank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:02:13 +04:00
claude b5500a5be8 Say where the answer lives, not just that it is a question (V-655)
A question was sorted twice. The cascade picked one of seven intents with
stage 0 rules, the resident model and the classifier behind it, a 91-case
fixture measuring it and the decision trace recording it. Then IntentQuery
handed the turn to a second dispatch in the daemon, twenty-two branches
deciding by seed similarity in a fixed order, with none of that. The careful
sorter did the easy half.

Decision grows a Source: twelve destinations, not twenty-two, because the
recall passes are one destination from the outside and so are the three world
sources. Empty is a real value and it is the floor — nothing names one, the
daemon walks its whole chain, and that is exactly what shipped before.

Stage 0 fills it where a deterministic rule already knows. Two new world rules
for the shapes measured failing on the box on 2026-08-07: "что такое TCP?" and
"кто такой Линус Торвальдс?" were answered by weather and by the personal
boundary, and "сколько будет 17 на 23?" was answered "для какого города?".
The calendar noun rule and the closed event-noun rule name the calendar. The
possessive agenda rules deliberately do not: "что у меня в списке покупок"
matches agenda-query, and naming the calendar there would take the list off
the turn.

Fixture unchanged at 69/91, which is the point — it scores intent, and none of
these cases changes intent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:01:59 +04:00
claude 9095ac847d Merge pull request #198 2026-08-07 10:24:39 +02:00
claude 4b5f6adbae Merge pull request #197 2026-08-07 10:22:07 +02:00
claude ecb8ba72eb Write down the bound on suspension (V-654) 2026-08-07 12:16:23 +04:00
claude 4fdecf9a25 Let a question go after it has stepped aside three times (V-654)
A side query suspends the parked question rather than dropping it. Nothing bounded that. No attempt is spent, so MaxAttempts never applies, and noteSuspended restarts the 90s clock, so the TTL cannot arrive while he keeps talking. Measured 2026-08-07: one unfilled time slot rode the tail of six consecutive unrelated replies.

PendingQuestion.Suspends counts the step-asides, MaxSuspends is 3, and past it she lets the request go with the same clarifyDropped line every other drop uses. The count is of consecutive step-asides and resets the moment he answers.

Also splits the re-ask off the answer into its own sentence. The comma splice buried the question in the tail of a reply about something else.
2026-08-07 12:16:15 +04:00
claude 2bbd8edbf6 Record the week of usage that found V-654 and its siblings (V-654)
Two untracked files left in the tree by the audit session. They are the evidence behind V-654 and several sibling tasks, so they belong on master rather than inside the PR that fixes one of them. Dated eval files under docs/evals/, so they are never edited after the day.
2026-08-07 11:59:40 +04:00
claude beb093aebb Run one test and audit the repo without retyping either (V-653)
Two commands replace work that 66 sessions of transcripts show being
redone by hand.

`make t` replaces the CGO preamble, pasted 391 times across past
sessions and documented in CLAUDE.md as the way to do it. It also sets
MAVEN_ONNX_LIB, which that recipe did not: the four TestONNX*
measurements self-skip without it and the run still prints "ok", so
every targeted eval done the old way reported the hash ratchet while
reading as a real embedder score. -race keeps it honest against `make
test`, -count=1 keeps a stale cache from passing as a result.

`make audit` replaces the inventory sweep. The four longest sessions
spent 93 greps rebuilding it before their first edit. Runs in 0.75s.

Its stub search is narrower than the sweeps were, on purpose. "not
wired" is this repo's word for a nil dependency and matched ~30
comments describing working code; "placeholder" names real identifiers
and matched 16 more; internal/ipc/unimplemented.go is the deliberate
Unimplemented*Server pattern, not 60 gaps. A gap report that reports
the architecture back at you is one nobody reads twice.
2026-08-07 03:13:08 +04:00
claude b1b326018f Merge pull request 'NEEDS-KAMI: telegram is the only reach, and it depends on a socks relay that has failed before' (#196) from task/649-needs-kami-telegram-is-the-only-reach-an into master 2026-08-07 00:50:34 +02:00
claude 08889cad88 Give the box a second reach (V-649)
Telegram was the only way off this box, and it is not a direct path: it
needs api.telegram.org, a socks relay on the host and a matching ufw rule.
Each of those three has failed once, and when they do a sev4 nudge has
nowhere to go. ntfy shares none of them.

The spare is the smaller half of it. The routing table already sends
sev3-away nudges and away reminders to ntfy and to nothing else, so with no
block configured those two routes hit a nil sink in DispatchNudge and
DispatchReminder and are skipped — no log line, no delivery_attempts row.
An away reminder is worse than dropped: out stays empty, so MarkReminder
never runs and it re-fires every tick without ever being delivered.

Owner's call, 07-08-2026: ntfy.kvmx.ru, topic maven.

The sink now takes a bearer token, which is what that server wants and what
it could not do before. ntfy scopes a token to one topic and to write-only,
so a popped sink can push to the maven topic and cannot read it back. Basic
auth stays for a server with no tokens; configuring both is refused rather
than resolved by guessing.

Config keys got json tags. docs/operations.md has documented this block as
base_url/topic since before it existed, and the untagged struct would only
have answered to BaseURL/Topic — the documented config would have parsed
into an empty one.

The token is a ${NTFY_TOKEN} expansion from the gitignored
deploy/telegram.env, beside the telegram secrets. TestDeployConfigLoads now
fails if the block goes missing, because deleting it is how you turn the
reach off and the two silent routes are what that costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
2026-08-07 02:16:18 +04:00
claude a4630b9314 Merge pull request 'MemoryStore.Search decodes and unmarshals every row before keeping topK' (#195) from task/643-memorystore-search-decodes-and-unmarshal into master 2026-08-07 00:09:50 +02:00
claude 39d44bb384 Close a Vikunja task with done, and nothing else (V-641)
Owner's call, 07-08-2026. A completion summary written into the
description on the way out is lost anyway, and the durable record is the
commit messages and the merged PR.

Written during the V-641 session and left uncommitted; it rides this
branch rather than being dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
2026-08-07 01:48:19 +04:00
claude 65ee0f9c61 Score every row, pay for only the ten that survive (V-643)
Search decoded the vector blob into a []float32 and JSON-unmarshalled the
meta map for every row, then sorted all N and threw away everything past
topK. Meta only ever matters for a survivor, and the sort answered a
question a bounded heap answers cheaper.

The scan still visits every row — that is what picks the winners. What it
no longer does is allocate for a row it is about to discard. dotBlob reads
the vector out of its stored bytes, so scoring costs nothing; a row is
copied and its meta unmarshalled only once it has entered the topK.

At 10000 rows and topK 10: 70.6ms to 26.8ms, 58MB to 17.5MB, 240k allocs
to 60k.

Recall is unchanged where it is measured. recall+onnx scores 22/32 with
recall@1 70.4% and recall@3 85.2%, identical to before.
TestMemoryStoreSearchMatchesNaive pins the ranking against the full-sort
implementation it replaced, and TestDotBlobMatchesDot pins bit-identical
scores, which the 0.008 gate margin demands.

One behaviour did move: ties. sort.Slice is not stable, so equal scores
were ordered arbitrarily; the heap now keeps the earliest. Under the real
embedder an exact tie is a duplicate vector and nothing moved. Under the
hash embedder the eval's floor uses, everything ties at 0 and that run's
recall@3 went 74.1% to 81.5% — a number that measures tie order, not
retrieval. recall@1 and false recall, the two the eval asserts, are
unchanged on both runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
2026-08-07 01:48:05 +04:00
claude 76938e206d Put a number on the recall scan before changing it (V-643)
MemoryStore.Search is on the per-turn recall path and had no benchmark, so
any claim about its cost was an argument rather than a measurement.

Seeds a store with rows the shape recall actually stores — 384-wide
vectors, the resident embedder's width, and a meta blob carrying the note
text — at 1000 and 10000 rows. 10000 is the ceiling the type doc claims a
full scan is fine at.

Measured as it stands: 5.3ms and 24k allocs at 1000 rows, 70.6ms and 240k
allocs at 10000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
2026-08-07 01:48:05 +04:00
claude 0b3d81ecbf Merge pull request 'Two maps grow for the process lifetime with no eviction' (#194) from task/641-two-maps-grow-for-the-process-lifetime-w into master 2026-08-06 23:33:23 +02:00
claude 4be6852b94 Drop host rate-limit entries that can no longer delay anything (V-641)
webfetch.Fetcher.last held one entry per distinct host the crawler ever
dialed, never pruned. Bounded in practice by how many hosts get crawled, but
crawl.on_demand is true in deploy, so the host set is whatever he names out
loud.

An entry older than HostInterval cannot delay a request — waitTurn would let
the next one straight through — so it is dropped. The sweep runs on write and
only once the map passes 64 entries, below which walking it costs more than
the entries do.

Rate limiting is unchanged: a host dialed inside the interval is kept, which
the test asserts, because pruning one would hand out a free turn.
2026-08-07 01:32:26 +04:00
claude f7b76c572f Bound the undated-item set per feed (V-641)
rss.Poller.seen held every undated item ever seen, one entry per id, for as
long as mavend ran. fresh() added and nothing removed. A feed that ships items
with no <pubDate> grew it forever.

seenIDs is the same set with a bound: the map answers the lookup, a slice
remembers insertion order, and the oldest id falls out past 512. The cap has
to stay above any one feed's front page or an item still listed there would be
written a second time, and a few hundred covers the largest page anyone
publishes. The set only ever had to span one poll window plus the resync
guard, not all of history.

Dedupe behaviour is unchanged. The comment at fresh() explains why the set
does not survive a restart; it never bounded it within one run.
2026-08-07 01:32:15 +04:00
claude 05ddc5c92e Merge pull request 'mavcaldav is built, documented as running, and deployed nowhere' (#193) from task/644-mavcaldav-is-built-documented-as-running into master 2026-08-06 23:22:18 +02:00
claude b55e68f98d Say in compose that the calendar is off, and why (V-644)
mavcaldav was built, in `make build`, listed in CLAUDE.md's daemon table, and
deployed nowhere. Not commented out the way mavmaild is, which at least
records the decision and the enable steps. Built and mentioned nowhere is the
worst of the three states, so this writes the decision down.

The box has no CalDAV account, so the block stays commented. It names what the
absence costs, because both costs are invisible from the daemon table. Agenda
questions route correctly and answer from nothing: stage 0 sends "что у меня
сегодня" to IntentQuery (V-498) and the calendar query source then reads facts
nobody writes. And loop.State.CalendarBusy is fed by those same facts, so the
gate's "do not nag mid-meeting" is permanently false.

CLAUDE.md said the absence was an oversight. It is a decision now.
2026-08-07 01:19:44 +04:00
claude beaa24754c Read the CalDAV password from a file, not from argv (V-644)
mavcaldav took -pass and -render-pass as flag values, so enabling it would
have put his calendar password in `ps` inside the container, in the compose
file, and in shell history. mavpoll and mavmaild both read their secret from
a file for exactly that reason.

readSecret reads once at start, trims, and refuses an empty or missing file.
An empty file is a deployment mistake, not a password, and basic auth would
otherwise send "" and collect a 401 every poll. A rotated password means a
restart, which is cheaper than re-reading the credential every five minutes.

Nothing called the old flags: no compose service, no systemd unit, no test.
So they are replaced rather than kept beside the new ones.
2026-08-07 01:19:33 +04:00
kami aed8cac439 Merge pull request 'The store caps sqlite at one connection under WAL, so every read queues behind every write' (#192) from task/642-the-store-caps-sqlite-at-one-connection into master 2026-08-06 23:04:39 +02:00
42 changed files with 2752 additions and 153 deletions
+127 -11
View File
@@ -58,16 +58,27 @@ make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build w
make test # go test -race across ./internal/... ./cmd/... with CGO env set
```
Run a single test (must carry the CGO env for packages that touch STT/TTS/voice):
Run one package or one test with `make t`. **Do not hand-write the CGO preamble.**
Past sessions pasted it about 390 times. That is where the shell-quoting failures
came from. This box runs zsh, so an unquoted `-run Test*` or `--include=*.go`
dies on "no matches found" before `go` is ever reached.
```sh
CGO_CFLAGS="-I$(pwd)/deps/include -I$(pwd)/deps/whisper.cpp/ggml/include" \
CGO_LDFLAGS="-L$(pwd)/deps/lib -Wl,-rpath,$(pwd)/deps/lib" \
LD_LIBRARY_PATH="$(pwd)/deps/lib" \
deps/go/go/bin/go test -run TestName ./internal/router/
make t PKG=./internal/router/
make t PKG=./cmd/mavend/ RUN=TestSimulator
make t PKG=./internal/router/eval/ RUN='TestONNX' V=1 # V=1 for -v, RACE=0 to drop -race
```
Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test ./pkg/`.
`t` carries `-race`, so a green `make t` cannot turn red under `make test`. It carries
`-count=1`, so a cached PASS from before your edit is never mistaken for a result.
It also sets `MAVEN_ONNX_LIB`, which the hand-written recipe did not. The four
`TestONNX*` measurements self-skip when that variable is unset. The run still prints
`ok`. So every targeted eval done the old way reported the hash ratchet while reading
as a real embedder score.
Pure-Go packages (`router`, `memory`, `mavweb`, …) also run under a plain `go test ./pkg/`,
but `make t` works everywhere and is one thing to remember.
## The daemons (`cmd/`)
@@ -101,9 +112,15 @@ protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from g
Count against compose, not against the table. Four of the nine daemons are absent, and each
absence has a different reason.
`mavmaild` is commented out in compose, with the reason written beside it: it needs a mail
account and this box has none. `mavcaldav` appears nowhere at all, and unlike the other
three that is an oversight rather than a decision (V-644).
`mavmaild` and `mavcaldav` are commented out in compose, each with the reason written
beside it: the first needs a mail account, the second a CalDAV account, and this box has
neither. `mavcaldav` used to appear nowhere at all, which was an oversight; it became a
recorded decision on 07-08-2026 (V-644). Two things ride on that absence and the block
names them. Agenda questions route to `IntentQuery` at stage 0 (V-498) and the `calendar`
query source then reads a table nobody writes. And `loop.State.CalendarBusy` is fed by the
same facts, so the gate's "do not nag mid-meeting" is permanently false. Its password is
read from a file (`-pass-file`, and `-render-pass-file` for the render collection), never
taken as a flag value, which is the rule `mavpoll` and `mavmaild` follow too.
**`mavwaked` and `mavenclient` are absent by decision, not oversight** (Vikunja #463,
`docs/plans/17-where-the-voice-loop-runs.md`).
@@ -340,6 +357,102 @@ 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 its own fixture and its own number as of 08-08-2026**
(V-659, `docs/evals/2026-08-08-destination-fixture.md`). This section used to say
it had neither. `want_source` on `eval.Case` is a pointer, because the destination
has three states and a bare string has two. Absent is every intent but query,
which never reaches `queryWalk`. Present and empty is the `SourceUnknown`
contract: name nothing and walk the chain. Present and named is a destination the
route must produce. Thirty-three of ninety-six cases carry one.
A destination miss does **not** fail the case. It lands in `Outcome.SourceReason`
and never in `Reasons`, so `Accuracy` and `IntentAccuracy` mean what they meant
and `SourceAccuracy` is a second number over the labelled cases only. Intent and
destination are two decisions, and one number hides which one moved. A route that
lost its intent scores no destination hit, or a clarify would satisfy an empty
label for free.
Measured classifier+ONNX: intent **73/96 (76.0%)**, destination **12/33 (36.4%)**.
The split is the finding. World is 5/5, because a stage 0 rule names it. The
`SourceUnknown` floor is 5/7. Calendar is 2/6, because the possessive agenda
rules deliberately do not name it. And **recall is 0/15, because nothing
anywhere names it**. Those turns are still answered, since the chain walks
recall early. Recall is the number the fourth head has to move.
Seven cases assert the floor and six of them are homelab operations. They
cluster because `SourceRecall`, `SourceNetwork` and `SourceAttention` overlap on
every question about the box. `mavpoll` writes its netdata and uptime-kuma
observations into the fact store recall reads. That is a finding about the enum,
not a gap in the labelling.
`baselineGrammars` in `eval_test.go` mirrors `buildRouter` and had drifted:
`WorldQueryGrammars` was wired into the daemon by V-655 and not into the mirror,
so the fixture scored a grammar set nobody runs. Fixed by V-659, worth 3 points of
destination and nothing else. Check that function when adding a grammar.
**The model arm landed the same day** (V-660,
`docs/evals/2026-08-08-destination-model-arm.md`). `routeGrammar` carries a
`source` rule closed over `router.Sources` plus the empty floor, so the model
cannot emit a destination that does not exist. The prompt lists the twelve in
Russian and says `""` is a normal answer to give often. `LLMRouter.Route` reads it
back through `ValidSource` and on `IntentQuery` alone. Against gemma-4-12b on the
workstation the cascade scores destination **24/33 (72.7%)** with intent unmoved
at 84.4%, and **recall goes 0/15 to 14/15**. The resident Qwen3-1.7B is
unmeasured, because it binds `--port 0` inside the container.
**Stage 0 now costs four destination points.** It did not before. The four cases
the cascade loses and the model alone wins are all calendar. The possessive
agenda rules claim them first and name nothing on purpose. That caution was free
while nothing downstream could name anything either. It is not free now, and the
fix is the owner's call rather than a quiet edit.
The last arm is 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, and 72.7% from a 12B teacher is the label source for training it.
## LLM output contract
All phrasing paths emit `{"response":"...","mood":"..."}`, with fallback to plain text when
@@ -454,8 +567,11 @@ start of a session rather than one lookup per first use:
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
```
`update_task` carrying a `description` resets `done` to false, so closing a task with a
write-up takes two calls: the description, then `done: true`.
**Close a finished task with `done: true` and nothing else** (owner's call, 07-08-2026).
Do not write a completion summary into the description on the way out. It is lost anyway,
and the durable record is the commit messages and the merged PR. Note that `update_task`
carrying a `description` resets `done` to false, which is why a write-up ever took two
calls.
## Session workflow
+40 -1
View File
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
.PHONY: t audit simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
all: build
@@ -128,6 +128,35 @@ test: fmt-check vet
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
# t — run ONE package or ONE test with the toolchain env already wired. This is
# the iteration target; `test` is the gate. Reach for it instead of pasting the
# CGO_CFLAGS/CGO_LDFLAGS/LD_LIBRARY_PATH preamble by hand, which is how it was
# done ~390 times across past sessions and is where the shell-quoting failures
# came from -- the interactive shell here is zsh, and an unquoted `-run Test*`
# or `--include=*.go` dies on "no matches found" before go ever starts.
#
# make t # whole tree (same scope as `test`)
# make t PKG=./internal/router/
# make t PKG=./cmd/mavend/ RUN=TestSimulator
# make t PKG=./internal/router/eval/ RUN='TestONNX' V=1
# make t PKG=./internal/store/ RACE=0 # drop -race when iterating hot
#
# -race is on by default so a green `make t` cannot turn red under `make test`.
# -count=1 because a cached PASS from before your edit is worse than no answer.
# MAVEN_ONNX_LIB is set for the same reason: the four TestONNX* measurements
# self-skip when it is unset, so a targeted eval run would otherwise report the
# deterministic hash ratchet and look like it scored the real embedder.
PKG ?= ./internal/... ./cmd/...
RUN ?=
V ?=
RACE ?= 1
t:
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" \
$(GO) test $(if $(V),-v,) $(if $(filter-out 0,$(RACE)),-race,) -count=1 \
$(if $(RUN),-run '$(RUN)',) $(PKG)
# eval-router — score the held-out RU routing fixture (internal/router/eval).
# Verbose so the report tables land in the terminal. MAVEN_ONNX_LIB points the
# prod-representative baseline at the vendored runtime; override it or set it
@@ -189,6 +218,16 @@ eval-models:
# scores the fixtures against ggml-small and self-skips when the model is
# absent, and TestGoldenFixturesAreCanonical, which checks the committed audio
# and the manifest with no model at all.
# audit — the repo inventory: LOC per package, open TODOs, real stubs, living-doc
# staleness, test shape, packages with no test. Read-only, prints, writes nothing.
# Run it instead of rebuilding the same greps by hand; past sessions spent 93 of
# them on this before their first edit. SECTION=loc|todo|stubs|docs|tests|gaps
# narrows it.
SECTION ?= all
audit:
@SECTION="$(SECTION)" ./scripts/audit.sh
stt-fixtures:
./scripts/gen-stt-fixtures.sh
+35 -8
View File
@@ -50,10 +50,10 @@ func run(args []string) error {
socket := fs.String("socket", "", "core IPC socket path (required)")
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
user := fs.String("user", "", "CalDAV basic-auth username (required)")
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
passFile := fs.String("pass-file", "", "file holding the CalDAV basic-auth password (required — never passed as a flag value)")
renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering")
renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)")
renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)")
renderPassFile := fs.String("render-pass-file", "", "file holding the password for -render-url (defaults to -pass-file)")
renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
@@ -63,13 +63,22 @@ func run(args []string) error {
if *socket == "" {
return fmt.Errorf("-socket is required")
}
if *url == "" || *user == "" || *pass == "" {
return fmt.Errorf("-url, -user, -pass are required")
if *url == "" || *user == "" || *passFile == "" {
return fmt.Errorf("-url, -user, -pass-file are required")
}
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
return err
}
// The password is read from a file, never taken as a flag value: an argv
// secret is visible in `ps` to every user on the box and lands in the compose
// file and the shell history. Same rule mavmaild and mavpoll follow. Read
// once at start, so a rotated password means a restart.
pass, err := readSecret(*passFile)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -85,17 +94,20 @@ func run(args []string) error {
http: hc,
url: strings.TrimRight(*url, "/"),
user: *user,
pass: *pass,
pass: pass,
}
var rend *renderer
if *renderURL != "" {
ru, rp := *renderUser, *renderPass
ru, rp := *renderUser, pass
if ru == "" {
ru = *user
}
if rp == "" {
rp = *pass
if *renderPassFile != "" {
rp, err = readSecret(*renderPassFile)
if err != nil {
return err
}
}
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
@@ -131,6 +143,21 @@ func run(args []string) error {
// It takes the whole read set, not one URL. The guarantee in the package
// comment is about every calendar maven reads, and a second read target added
// later must not quietly fall outside the check.
// readSecret reads one credential from a file and refuses an empty one. An
// empty file is a deployment mistake, not a password, and CalDAV basic auth
// would send it and get a 401 every poll.
func readSecret(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read password file: %w", err)
}
secret := strings.TrimSpace(string(raw))
if secret == "" {
return "", fmt.Errorf("password file %s is empty", path)
}
return secret, nil
}
func checkRenderTarget(readURLs []string, renderURL string) error {
if renderURL == "" {
return nil
+26
View File
@@ -5,12 +5,38 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
// The password comes from a file so it never reaches argv. An empty or missing
// file must fail at start rather than authenticate as "" against his calendar.
func TestReadSecret(t *testing.T) {
dir := t.TempDir()
good := filepath.Join(dir, "ok")
if err := os.WriteFile(good, []byte(" hunter2\n"), 0o600); err != nil {
t.Fatal(err)
}
if got, err := readSecret(good); err != nil || got != "hunter2" {
t.Fatalf("readSecret(good) = %q, %v; want \"hunter2\", nil", got, err)
}
empty := filepath.Join(dir, "empty")
if err := os.WriteFile(empty, []byte("\n \n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := readSecret(empty); err == nil {
t.Fatal("readSecret(empty) = nil error, want refusal")
}
if _, err := readSecret(filepath.Join(dir, "absent")); err == nil {
t.Fatal("readSecret(absent) = nil error, want refusal")
}
}
type fakeCore struct {
ipc.UnimplementedCoreAPI
facts map[string]ipc.Fact // composite key "key|source" → Fact
+85 -23
View File
@@ -59,6 +59,26 @@ type querySource struct {
// sources search text with no notion of a day. When one of them grows a
// date parameter, flip its flag here.
dateAware bool
// dest — the destination this source serves, when the cascade named one
// (V-655). Several sources share a destination: the three recall passes and
// the fact-by-key lookup are all SourceRecall, because which of them lands
// the hit is an ordering detail no utterance can name. A source with no
// dest is reachable only by walking the chain.
dest router.Source
// guesses — this source decides whether the turn is its own by scoring the
// utterance against frozen seeds, rather than by looking something up and
// coming back empty.
//
// The distinction is the whole point of the field. A source that looks can
// be wrong about relevance and still harmless, because the miss shows up as
// no rows. A source that guesses answers whatever it claims: weather has no
// local table to miss against, so "что такое TCP?" became "для какого
// города?". So when the cascade names a destination, the guessers that were
// not named do not get to try. The lookups still run, because a named
// destination is evidence and not a promise.
guesses bool
}
// querySources is the ordered chain actionQuery walks; first source to claim
@@ -67,85 +87,85 @@ type querySource struct {
// gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one
// line here plus its method; where you put the line is the whole decision.
var querySources = []querySource{
{name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey},
{name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey, dest: router.SourceRecall},
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
// the more specific ask (its matcher requires a plan word), so the calendar
// listing would otherwise swallow it.
{name: "day-plan", answer: (*reactiveHandler).queryDayPlan},
{name: "day-plan", answer: (*reactiveHandler).queryDayPlan, dest: router.SourceCalendar},
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
// and the habit question is the more specific one. Its matcher requires a
// habit marker ("обычно", "каждый", …), so a question about this coming
// Wednesday still reaches the calendar.
{name: "habits", answer: (*reactiveHandler).queryHabits},
{name: "habits", answer: (*reactiveHandler).queryHabits, dest: router.SourceCalendar},
// Before "calendar" and before the recall sources: "что мне нужно
// сделать?" is a question about the task list, and the notes pass would
// otherwise answer it with whatever note happens to be nearest. Its
// matcher requires a task noun or an explicit "что … сделать", so a
// date-bearing question still reaches the calendar.
{name: "tasks", answer: (*reactiveHandler).queryTasks},
{name: "tasks", answer: (*reactiveHandler).queryTasks, dest: router.SourceTasks},
// Next to "tasks" and for the same reason: "что требует внимания?" is a
// question about the operational state Praxis holds, and it used to fall
// through every source to the web search (Vikunja #475). Its matcher needs
// an attention marker, and it falls through when Praxis is not configured.
{name: "attention", answer: (*reactiveHandler).queryAttention},
{name: "attention", answer: (*reactiveHandler).queryAttention, dest: router.SourceAttention, guesses: true},
// Next to "tasks" and for the same reason: "что мне купить?" is a question
// about the shopping list, and the recall pass would otherwise answer it
// from an old note about the shop. Its matcher needs an explicit list
// marker, so "надо бы съездить в магазин" is untouched.
{name: "list", answer: (*reactiveHandler).queryList},
{name: "list", answer: (*reactiveHandler).queryList, dest: router.SourceList, guesses: true},
// Before the recall sources too: "сколько я потратил?" is a question about
// the money facts the poller wrote, and the notes pass would otherwise
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
{name: "money", answer: (*reactiveHandler).queryMoney},
{name: "money", answer: (*reactiveHandler).queryMoney, dest: router.SourceMoney},
// Also above the recall sources: "что я тебе говорил?" is a question about
// the facts he tapped in, and the notes pass would answer it with whatever
// note is nearest (Vikunja #456). Its matcher needs both halves of a
// history phrase and bails out when he names a topic, so "что я говорил
// про сервер" is still recall.
{name: "history", answer: (*reactiveHandler).queryHistory},
{name: "history", answer: (*reactiveHandler).queryHistory, dest: router.SourceRecall},
// Before the recall sources and before general knowledge: "что нового?" is
// a question about the feeds she reads, and general knowledge would answer
// it by inventing news. Its matcher needs a feed noun plus an ask, so
// "у меня новая лента в инстаграме" is untouched.
{name: "feeds", answer: (*reactiveHandler).queryFeeds},
{name: "feeds", answer: (*reactiveHandler).queryFeeds, dest: router.SourceFeeds, guesses: true},
// Before "calendar" and before the recall sources: "что включено дома?" is
// a question about the house, and the notes pass would otherwise answer it
// from whatever he once said about the lights. Its matcher needs a house
// marker plus an ask plus a device word, and it bails out on weather
// wording, so "какая температура на улице?" still reaches the weather
// source.
{name: "home", answer: (*reactiveHandler).queryHome},
{name: "home", answer: (*reactiveHandler).queryHome, dest: router.SourceHome, guesses: true},
// Next to "home" and for the same reason: "какие устройства в сети?" is a
// question about the LAN, and the recall pass would otherwise answer it
// from an old note about the router. Its matcher needs a network word plus
// an ask plus a device noun, so "интернет не работает" is untouched.
{name: "network", answer: (*reactiveHandler).queryNetwork},
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
{name: "weather", answer: (*reactiveHandler).queryWeather},
{name: "network", answer: (*reactiveHandler).queryNetwork, dest: router.SourceNetwork, guesses: true},
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true, dest: router.SourceCalendar},
{name: "weather", answer: (*reactiveHandler).queryWeather, dest: router.SourceWeather, guesses: true},
// A question about her, above the three sources that search his own data
// (Vikunja #555). It has no answer anywhere else: below the boundary
// SearXNG answers about somebody else's assistant, and above it his notes
// answer by proximity — "кто ты" came back from a note of his, measured on
// the box, because the recall index has no idea the subject is her.
{name: "self", answer: (*reactiveHandler).querySelf},
{name: "embed", answer: (*reactiveHandler).queryEmbed},
{name: "memory", answer: (*reactiveHandler).queryMemory},
{name: "notes", answer: (*reactiveHandler).queryNotes},
{name: "self", answer: (*reactiveHandler).querySelf, dest: router.SourceSelf, guesses: true},
{name: "embed", answer: (*reactiveHandler).queryEmbed, dest: router.SourceRecall},
{name: "memory", answer: (*reactiveHandler).queryMemory, dest: router.SourceRecall},
{name: "notes", answer: (*reactiveHandler).queryNotes, dest: router.SourceRecall},
// THE BOUNDARY. Everything above answers from his own data; everything
// below answers from the world's. A question about him that got this far
// has no answer in his data, and no outside source can supply one, so this
// stops the walk rather than let the encyclopedia and the model guess.
{name: "personal", answer: (*reactiveHandler).queryPersonal},
{name: "personal", answer: (*reactiveHandler).queryPersonal, dest: router.SourceRecall, guesses: true},
// The world, read live. Owner's ruling of 2026-08-02: a metasearch hit beats
// a frozen ZIM, so SearXNG asks before Kiwix does. Nothing of his is at
// stake by this point — the boundary above already stopped every question
// about him, and only the query string leaves the box.
{name: "search", answer: (*reactiveHandler).querySearch},
{name: "search", answer: (*reactiveHandler).querySearch, dest: router.SourceWorld},
// The offline encyclopedia, now the fallback for when the line is down or
// the search comes back empty. It reads the way it always did; what changed
// is that it no longer gets first refusal on a world question.
{name: "kiwix", answer: (*reactiveHandler).queryKiwix},
{name: "kiwix", answer: (*reactiveHandler).queryKiwix, dest: router.SourceWorld},
// LAST before the model answers from memory, and that position is the whole
// design (Vikunja #259): everything of his, then the search, then the ZIMs,
// and only then a page he named. The model does NOT come first: it
@@ -153,8 +173,43 @@ var querySources = []querySource{
// a 1.7B guessing at a page it cannot read is how contents get invented.
// This source only claims a turn where he named a URL, so it never competes
// with a local answer.
{name: "web", answer: (*reactiveHandler).queryWeb},
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral},
{name: "web", answer: (*reactiveHandler).queryWeb, dest: router.SourceWorld},
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral, dest: router.SourceWorld},
}
// queryWalk narrows the chain for one turn against the destination the cascade
// named, and says which sources were left out (V-655).
//
// It takes sources OUT and never moves one, which is the whole safety argument.
// The table's order is load-bearing and every comment on it argues a reason
// between two sources; none of those reasons is about this. Above all, the
// order carries "his data first, then the world", and a destination named by a
// model must not be able to reverse that. Naming SourceWorld does not send the
// turn outside — it stops the guessers from claiming it on the way.
//
// What comes out is exactly the sources that guess. Those decide whether a turn
// is theirs by scoring it against frozen seeds, and then answer whatever they
// claimed, because they have no lookup that can come back empty. That is the
// whole of the 2026-08-07 defect: weather claiming "что такое TCP?", the feed
// claiming "какой у меня любимый язык?", the personal boundary claiming "кто
// такой Линус Торвальдс?". The sources that look are all still asked, so a
// wrong destination costs nothing but the guess it prevented.
//
// No destination named ⇒ the table exactly as written, which is what shipped
// before the field existed. That is the floor. The classifier arm names
// nothing, so a box whose model is down routes queries the way it always did.
func queryWalk(dest router.Source) (walk, skipped []querySource) {
if dest == router.SourceUnknown {
return querySources, nil
}
for _, s := range querySources {
if s.guesses && s.dest != dest {
skipped = append(skipped, s)
continue
}
walk = append(walk, s)
}
return walk, skipped
}
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
@@ -164,7 +219,14 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
// (V-564). Finish names everyone below the winner.
decision.Expect(ctx, decision.StageQuery, querySourceNames())
rec := decision.From(ctx)
for _, src := range querySources {
walk, skipped := queryWalk(dec.Source)
for _, src := range skipped {
rec.Note(decision.Claim{
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
Reason: "it decides by similarity and the cascade named " + string(dec.Source),
})
}
for _, src := range walk {
if dec.Continued && !src.dateAware {
rec.Note(decision.Claim{
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
+42 -22
View File
@@ -6,7 +6,6 @@ import (
"math/rand"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/kami/maven/internal/dialogue"
@@ -162,12 +161,13 @@ func withNotice(notice, reply string) string {
// напоминание?" — answer first, then the open question. A question in front of
// its own answer would read as ignoring what he asked.
//
// A statement's full stop is folded into a comma, so the two acts read as one
// sentence — that is the owner's own punctuation, "в Риме сейчас ..., на какое
// время поставить напоминание?". An answer that is ITSELF a question keeps its
// mark and the resume starts a new sentence: she sometimes answers a side query
// by asking him to say it again, and "переформулировать?, на какое время" folds
// two questions into one unreadable line.
// Two sentences, not one (V-654). This used to fold the answer's full stop into
// a comma, on the strength of the owner having written it that way once. Spliced
// onto a real answer it reads as one run-on thought — "вот что я нашла: вайфай
// пароль лежит в ящике стола, на какое время поставить напоминание?" — and the
// question disappears into the tail of a sentence about something else. A reply
// with no terminator of its own is given one, so the join never depends on how
// the phraser chose to end.
//
// A resume with no answer in front of it is just the question.
func withResumed(reply, resumed string) string {
@@ -178,23 +178,17 @@ func withResumed(reply, resumed string) string {
if reply == "" {
return resumed
}
if strings.HasSuffix(reply, "?") {
return reply + " " + resumed
if !endsSentence(reply) {
reply += "."
}
if trimmed := strings.TrimRight(reply, ".!"); trimmed != "" {
reply = trimmed
}
return reply + ", " + lowerFirst(resumed)
return reply + " " + resumed
}
// lowerFirst lowercases the opening rune, so a deck line written as a standalone
// sentence reads as the second half of one. Only the first rune: "На какое
// время" must become "на какое время" and nothing else in it may move.
func lowerFirst(s string) string {
for i, r := range s {
return string(unicode.ToLower(r)) + s[i+utf8.RuneLen(r):]
}
return s
// endsSentence reports whether s already closes itself. The ellipsis counts: a
// trailing "…" is a deliberate end, and a full stop after it reads as a typo.
func endsSentence(s string) bool {
r, _ := utf8.DecodeLastRuneInString(s)
return strings.ContainsRune(".!?…", r)
}
// missingFor returns the slots a decision still needs, most important first.
@@ -381,6 +375,14 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
return "", false
}
// He is answering, so the run of step-asides is over (V-654). Reset here
// rather than where a gap is FILLED: "позвонить маме" against a question
// about the time gives her nothing she asked for and still means he is in
// the exchange, and the retry it costs is bound enough on its own. The
// counter is for the case the bounds miss — he asked for other things and
// never came back.
q.Suspends = 0
merged := q.Answer(text, toDialogueSlots(answer))
// Fold a newly answered subject into the raw utterance. Downstream actions
// phrase from Utterance, not from the text slot — actionReminder stores it
@@ -467,6 +469,14 @@ func (h *reactiveHandler) noteDropped(ctx context.Context) {
//
// A slot with no resumed wording (clarifyResumedFor says so) resumes nothing and
// says nothing. She must not claim to be holding a question she cannot re-ask.
//
// Suspension is bounded, since V-654. Neither of the two things above is a
// limit: no attempt is spent, and restarting the clock means the TTL cannot
// arrive while he keeps talking. So the count is the only thing that ends it,
// and past MaxSuspends she lets the request go and says so with the same line
// every other drop uses. The rule is unchanged — a question ends by being
// answered or by being let go out loud — this only recognises three unrelated
// requests in a row as the second of those.
func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) {
rt := turnRouteFrom(ctx)
if rt == nil || len(q.Missing) == 0 {
@@ -476,11 +486,18 @@ func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.Pending
if !ok {
return
}
if !q.CanResume() {
h.clarifyStore.Delete(dialogueIDOf(ctx))
h.noteDropped(ctx)
log.Printf("voice: clarify — the question about %s stepped aside %d times; letting the request go", q.Missing[0], q.Suspends)
return
}
q.Suspends++
q.Asked = h.now()
h.clarifyStore.Put(dialogueIDOf(ctx), q)
rt.resume = question
rt.suspended = true
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply", q.Missing[0])
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends)
}
// foldAnswerIntoUtterance appends an answered subject to the original words,
@@ -520,6 +537,9 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
if !ok || !q.CanAsk() {
return "", false
}
// Suspends is not carried, and by this point it is already zero: the answer
// path resets it (V-654). Left off the literal so the zero is stated where
// the struct is built, rather than inherited from a field nobody names.
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
Intent: q.Intent,
Slots: merged,
+50
View File
@@ -740,3 +740,53 @@ func TestACompleteTurnStillDoesNotAsk(t *testing.T) {
}
}
}
// TestTheResumedQuestionIsItsOwnSentence — V-654. The re-ask used to be spliced
// onto the answer with a comma, so a real answer and an unrelated open question
// read as one run-on thought and the question vanished into its tail.
func TestTheResumedQuestionIsItsOwnSentence(t *testing.T) {
const resumed = "На какое время поставить напоминание?"
cases := []struct {
name string
reply string
want string
}{
{
// The measured line, shortened. Two sentences, and the question keeps
// its capital.
name: "a statement keeps its full stop",
reply: "Вайфай пароль лежит в ящике стола.",
want: "Вайфай пароль лежит в ящике стола. " + resumed,
},
{
name: "a reply with no terminator is given one",
reply: "Вайфай пароль лежит в ящике стола",
want: "Вайфай пароль лежит в ящике стола. " + resumed,
},
{
// She sometimes answers a side query by asking him to say it again.
// Two questions, and neither may swallow the other.
name: "a question keeps its mark",
reply: "Можешь переформулировать?",
want: "Можешь переформулировать? " + resumed,
},
{
name: "an ellipsis is already an ending",
reply: "Не уверена…",
want: "Не уверена… " + resumed,
},
{
name: "a resume with no answer in front of it is just the question",
reply: "",
want: resumed,
},
}
for _, tc := range cases {
if got := withResumed(tc.reply, resumed); got != tc.want {
t.Errorf("%s: withResumed(%q) = %q, want %q", tc.name, tc.reply, got, tc.want)
}
}
if got := withResumed("Готово.", ""); got != "Готово." {
t.Errorf("nothing to resume must leave the reply alone, got %q", got)
}
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"testing"
"github.com/kami/maven/internal/router"
)
// The floor, and it is the reason a destination is safe to add at all: a box
// whose model is down names nothing, and naming nothing has to walk the chain
// the way it walked before the field existed.
func TestNoDestinationWalksTheWholeChain(t *testing.T) {
walk, skipped := queryWalk(router.SourceUnknown)
if len(skipped) != 0 {
t.Errorf("skipped %d sources with no destination named, want none", len(skipped))
}
if len(walk) != len(querySources) {
t.Fatalf("walk has %d sources, want the whole table of %d", len(walk), len(querySources))
}
for i := range walk {
if walk[i].name != querySources[i].name {
t.Fatalf("position %d is %q, want %q", i, walk[i].name, querySources[i].name)
}
}
}
// The 2026-08-07 defects, one per line. Each is a source that decides by seed
// similarity claiming a turn that was never its own, and then answering it
// because it has no lookup that could come back empty.
func TestANamedDestinationSilencesTheOtherGuessers(t *testing.T) {
cases := []struct {
dest router.Source
utterance string
silenced string
}{
{router.SourceWorld, "что такое TCP?", "weather"},
{router.SourceWorld, "сколько будет 17 на 23?", "weather"},
{router.SourceWorld, "кто такой Линус Торвальдс?", "personal"},
{router.SourceRecall, "какой у меня любимый язык?", "feeds"},
{router.SourceCalendar, "что в календаре на завтра?", "weather"},
}
for _, c := range cases {
walk, skipped := queryWalk(c.dest)
if inWalk(walk, c.silenced) {
t.Errorf("%q named %q: %q is still asked", c.utterance, c.dest, c.silenced)
}
if !inWalk(skipped, c.silenced) {
t.Errorf("%q named %q: %q is missing from the record of who was skipped",
c.utterance, c.dest, c.silenced)
}
}
}
// Naming the world must not send the turn outside. His notes, his facts and the
// boundary in front of them are the invariant CLAUDE.md states as "the owner's
// data first, then the world", and a destination a model wrote must not be able
// to reverse it.
func TestNamingTheWorldStillReadsHisDataFirst(t *testing.T) {
walk, _ := queryWalk(router.SourceWorld)
for _, look := range []string{"fact-by-key", "embed", "memory", "notes"} {
if !inWalk(walk, look) {
t.Errorf("%q was dropped; only the sources that guess may be dropped", look)
}
}
if posOf(walk, "notes") > posOf(walk, "search") {
t.Error("search is asked before his notes are")
}
if posOf(walk, "search") < 0 {
t.Fatal("search is not in the walk at all")
}
}
// The boundary belongs to his data, so naming recall keeps it. That is what
// makes "какой у меня любимый язык?" answer "не нашла у тебя такой записи"
// rather than reaching SearXNG once nothing local had it.
func TestNamingRecallKeepsTheBoundary(t *testing.T) {
walk, _ := queryWalk(router.SourceRecall)
if !inWalk(walk, "personal") {
t.Fatal("the personal boundary was skipped on a turn named for his own data")
}
if posOf(walk, "personal") > posOf(walk, "search") {
t.Error("the boundary no longer sits in front of the world")
}
}
// Whatever the destination, the walk is a subsequence of the table. Every
// comment on that table argues an order between two sources, and none of those
// reasons is about this field.
func TestTheWalkNeverReordersTheTable(t *testing.T) {
for _, dest := range append([]router.Source{router.SourceUnknown}, router.Sources...) {
walk, skipped := queryWalk(dest)
if len(walk)+len(skipped) != len(querySources) {
t.Errorf("%q: %d walked + %d skipped, want %d", dest, len(walk), len(skipped), len(querySources))
}
last := -1
for _, s := range walk {
at := posOf(querySources, s.name)
if at <= last {
t.Errorf("%q: %q is out of table order", dest, s.name)
}
last = at
}
}
}
func inWalk(list []querySource, name string) bool { return posOf(list, name) >= 0 }
func posOf(list []querySource, name string) int {
for i, s := range list {
if s.name == name {
return i
}
}
return -1
}
+94
View File
@@ -279,3 +279,97 @@ func TestTheTurnIsRoutedOnce(t *testing.T) {
t.Fatalf("the pipeline routed again and got something else: %+v vs %+v", second, first)
}
}
// TestASuspendedQuestionDoesNotRideForever — V-654, the measured failure of
// 2026-08-07 (docs/evals/2026-08-07-week-of-usage-transcript.md, t=51 to t=58).
//
// A side query suspends the parked question, spends no attempt and restarts the
// TTL. Nothing else bounded it, so one unfilled time slot came back on the end
// of six consecutive unrelated replies and stopped only when a seventh turn
// happened to read as a failed answer. Three step-asides, then she lets it go
// and says so.
func TestASuspendedQuestionDoesNotRideForever(t *testing.T) {
ctx := context.Background()
h, st := newRoutingClarifyHandler(t)
id := dialogueIDFor(sourceText, "web")
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
t.Fatalf("expected the time question, got %q", reply)
}
// Three questions of his own. Each one is answered as itself and each one
// brings the open question back, exactly as V-561 asks.
asides := []string{
"о чём мы вчера говорили?",
"какие у меня напоминания?",
"сколько времени?",
}
for i, text := range asides {
reply := h.handleText(ctx, "web", text)
if !strings.HasSuffix(reply, resumed) {
t.Fatalf("side query %d: the question must come back, got %q", i+1, reply)
}
if strings.Contains(reply, clarifyDropped) {
t.Fatalf("side query %d: nothing was let go yet, so nothing may say so: %q", i+1, reply)
}
q := h.clarifyStore.Get(id, h.now())
if q == nil {
t.Fatalf("side query %d: the question was dropped early", i+1)
}
if q.Attempts != 1 {
t.Fatalf("side query %d: a step-aside spent an attempt: %d", i+1, q.Attempts)
}
if q.Suspends != i+1 {
t.Fatalf("side query %d: suspends = %d, want %d", i+1, q.Suspends, i+1)
}
}
// The fourth. She has stepped aside as often as she is willing to, so the
// request goes — out loud, and without the question on the tail.
reply := h.handleText(ctx, "web", "что у меня сегодня?")
if !strings.Contains(reply, clarifyDropped) {
t.Fatalf("the request was let go in silence: %q", reply)
}
if strings.HasSuffix(reply, resumed) {
t.Fatalf("a question she has let go must not be asked again: %q", reply)
}
if h.clarifyStore.Get(id, h.now()) != nil {
t.Fatal("the question must be gone once she has said she let it go")
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
t.Fatalf("a reminder was invented for a time nobody gave: %v err=%v", reminders, err)
}
}
// TestAnAnsweredGapResetsTheSuspendBudget — the counter measures CONSECUTIVE
// step-asides. He filled a gap, so the run is broken and the next question
// starts with its full allowance: a long exchange he is engaged with must not
// run out of patience on his behalf.
func TestAnAnsweredGapResetsTheSuspendBudget(t *testing.T) {
ctx := context.Background()
h, _ := newRoutingClarifyHandler(t)
id := dialogueIDFor(sourceText, "web")
// A bare "напомни" is missing both halves, so answering the subject re-parks
// the request with a question about the time.
if reply := h.handleText(ctx, "web", "напомни"); !strings.Contains(reply, "?") {
t.Fatalf("expected a question, got %q", reply)
}
if reply := h.handleText(ctx, "web", "какие у меня напоминания?"); reply == "" {
t.Fatal("the side query must be answered as itself")
}
if q := h.clarifyStore.Get(id, h.now()); q == nil || q.Suspends != 1 {
t.Fatalf("the side query was not counted: %+v", q)
}
if reply := h.handleText(ctx, "web", "позвонить маме"); reply == "" {
t.Fatal("the answer must be consumed")
}
q := h.clarifyStore.Get(id, h.now())
if q == nil {
t.Fatal("a reminder still needs its time, so a question must be parked")
}
if q.Suspends != 0 {
t.Fatalf("answering a gap must reset the suspend budget: suspends = %d", q.Suspends)
}
}
+4
View File
@@ -401,6 +401,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
grammars = append(grammars, router.AgendaQueryGrammars()...)
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
// routed system and answered "пока не умею" (Vikunja #474).
// After the agenda rules, which are the narrower claim, and BEFORE the feed
// and list rules, which are not: "что такое лента" is a definition question
// and the feed rule would take it on the noun alone (V-655).
grammars = append(grammars, router.WorldQueryGrammars()...)
grammars = append(grammars, router.FeedQueryGrammar())
// The list side of the same exposure: a phrasing with no possessive in it
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
+19
View File
@@ -25,6 +25,25 @@
"llm_nudges": false
},
"//ntfy": [
"The second reach (V-649). Until 07-08-2026 telegram was the only one, and",
"telegram needs api.telegram.org, the socks relay below and a matching ufw",
"rule — three things in series that have each failed once, and when they do",
"a sev4 nudge has nowhere to go. ntfy shares none of them: it is reached",
"directly, no relay.",
"It is not only a spare. The routing table sends sev3-away and away",
"reminders here and NOWHERE else, so with this block absent those two",
"routes hit a nil sink and vanish without a log or an outbox row.",
"The credential is an ntfy access token, scoped write-only to this one",
"topic, so a popped sink can push to it and cannot read it back. Set it in",
"deploy/telegram.env beside the telegram secrets; that file is gitignored."
],
"ntfy": {
"base_url": "https://ntfy.kvmx.ru",
"topic": "maven",
"token": "${NTFY_TOKEN}"
},
"telegram": {
"bot_token": "${TELEGRAM_BOT_TOKEN}",
"chat_id": "${TELEGRAM_CHAT_ID}",
+8 -1
View File
@@ -1,5 +1,12 @@
# Telegram bot token and chat ID for mavend's away-channel reach.
# Secrets for mavend's away-channel reaches. The file is still called
# telegram.env because compose names it that; it holds both reaches now.
# Copy this file to deploy/telegram.env and fill in real values.
# deploy/telegram.env is gitignored — never commit the real secrets.
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# ntfy access token for the `maven` topic, the second reach (V-649). Mint it on
# the ntfy server with write access to that topic and nothing else:
# ntfy token add --expires=never maven
# Read access is not needed — mavend publishes and never subscribes.
NTFY_TOKEN=
+38
View File
@@ -157,6 +157,44 @@ services:
# - maildata:/var/lib/mavmaild
# - ./deploy/imap.password:/run/secrets/imap.password:ro
# The calendar reader (Vikunja #644) is OFF and commented out: it needs a
# CalDAV account, and there is none on this box. It was built, listed in
# `make build`, and deployed nowhere, which is the worst of the three states —
# this block records the decision instead.
#
# What its absence costs, so the cost is visible from here:
# - Agenda questions route correctly and answer from nothing. Stage 0 sends
# "что у меня сегодня" to IntentQuery (V-498) and the `calendar` query
# source reads facts(kind=env, source=caldav:*) that nobody writes.
# - The nudge gate loses a suppressor. loop.State.CalendarBusy is fed by
# those same facts, so "do not nag mid-meeting" is permanently false.
#
# Core never sees the CalDAV password: the reader polls the collection itself
# and hands core one fact per event over WriteFact. Nothing here can create a
# reminder, so a misread event cannot fire.
#
# The password is read from a FILE, so it never appears in `ps`, in this file,
# or in shell history — the same rule mavpoll and mavmaild follow.
#
# To enable: write the password to deploy/caldav.password (0600, gitignored),
# point -url at the collection, and uncomment this service. No mavend.json
# block is needed — events arrive over IPC as facts. -render-url is optional
# and OFF here: it publishes Maven's own reminders back as events, and it must
# not name the collection -url reads, or the poller reads its own writes back
# in (checkRenderTarget refuses that). It takes -render-pass-file, and falls
# back to this password when that is not given.
# mavcaldav:
# <<: *image
# command: ["mavcaldav", "-socket", "/run/maven/mavend.sock",
# "-url", "http://localhost:5232/kami/personal",
# "-user", "kami",
# "-pass-file", "/run/secrets/caldav.password",
# "-interval", "5m"]
# depends_on: [mavend]
# volumes:
# - sockets:/run/maven
# - ./deploy/caldav.password:/run/secrets/caldav.password:ro
volumes:
dbdata:
sockets:
+34 -1
View File
@@ -1,6 +1,6 @@
# Maven — Design
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
*Last verified: 2026-08-07 @ beb093a. Living doc: correct it in place, do not append.*
> Folded 2026-07-30 from `SPEC.md` (north star, 2026-07-03), `maven.md`
> (consolidated decisions, 2026-06-30) and `ROADMAP.md` (execution plan,
@@ -282,6 +282,39 @@ Three reasons, in the order they settle it:
So the notice stays what it is: the in-process TTL case, where she really did
wait and really did let go.
#### A parked question may step aside three times
Decided 2026-08-07 (V-654). A side query or an aside suspends the parked
question instead of dropping it. The words are answered as themselves, and the
question comes back on the end of the same reply.
Neither bound on a question reaches that path. No attempt is spent, because a
side query is not a failed answer, so `MaxAttempts` never applies.
`noteSuspended` also restarts the 90s clock, since she is about to speak the
question again. So the TTL cannot arrive while he keeps talking.
Measured on 2026-08-07: one unfilled time slot rode the tail of six consecutive
unrelated replies. It stopped only when a seventh turn happened to read as a
failed answer. See `docs/evals/2026-08-07-week-of-usage.md`.
`PendingQuestion.Suspends` counts the step-asides. `MaxSuspends` is 3, matching
`DefaultMaxAttempts`. Past it she lets the request go, with the same
`clarifyDropped` line every other drop uses. The owner's rule is unchanged. A
question still ends by being answered or by being let go out loud. This only
recognises three unrelated requests in a row as the second of those.
The count is of CONSECUTIVE step-asides. It resets the moment he answers, in
`resolveClarifyAnswer`. An answer that gives her nothing she asked for resets it
too. "Позвонить маме" against a question about the time is still him in the
exchange. The retry it costs is bound enough on its own.
The re-ask is also two sentences rather than one. It used to be spliced onto the
answer with a comma. On a real answer that buries the question in the tail of
one run-on thought:
> вот что я нашла: вайфай пароль лежит в ящике стола, на какое время поставить
> напоминание?
### save-where — the two-memory routing axis
One discriminator: **does the loop evaluate a predicate against it?**
@@ -0,0 +1,321 @@
# Raw transcript, 2026-08-07 week of usage
Companion to `2026-08-07-week-of-usage.md`. 74 turns through `POST /api/chat`.
Days are simulated; the wall clock was 03:27-03:40 throughout, which is why every
clock answer says night.
## Day 1
```
YOU: доброе утро
MAVEN: Добро пожаловать! Солнечно и тепло. Всё в порядке.
[7.7s t=5]
YOU: какой сегодня день?
MAVEN: сегодня пятница, 7 августа 2026 года
[0.0s t=6]
YOU: напомни в 11:00 позвонить маме
MAVEN: Сейчас 03:29. В какой день?
[0.4s t=7]
YOU: запиши что я пью кофе без сахара
MAVEN: Прошлую просьбу отпускаю. Заметка сохранена.
[8.0s t=8]
YOU: что у меня сегодня?
MAVEN: на 07.08.2026 ничего нет.
[2.6s t=9]
YOU: мой любимый язык программирования — go
MAVEN: Хорошо, сохранил.
[7.6s t=10]
YOU: сколько времени?
MAVEN: сейчас 3 часа 29 минут
[0.0s t=11]
YOU: спасибо
MAVEN: спасиба
[8.8s t=12]
YOU: сегодня
MAVEN: Прости, не поняла — попробуй сказать иначе.
[5.1s t=13]
YOU: а что ты умеешь?
MAVEN: я могу помочь с задачами, напоминаниями и поиском информации.
[4.9s t=14]
YOU: почему небо голубое?
MAVEN: нашла вот это: Почему небо голубое: объяснение цвета неба простыми словами, физика ... — Ответ на вопрос, почему небо голубое, кроется в особенностях солнечного света и его взаимодействии с атмосферой Земли. Рассказываем, как это работает. В статье разберемся, почему днем небо кажется голубым, а на закате окрашива…
[12.3s t=15]
YOU: столица Франции?
MAVEN: пока не умею отвечать на этот вопрос.
[1.5s t=16]
YOU: кто такой Линус Торвальдс?
MAVEN: не знаю — не нашла у тебя такой записи.
[1.8s t=17]
YOU: ладно, до вечера
MAVEN: Ладно, до вечера.
[6.3s t=18]
```
## Day 2
```
YOU: привет, как спалось?
MAVEN: пока не умею отвечать на этот вопрос.
[1.3s t=19]
YOU: что я пью?
MAVEN: не знаю — не нашла у тебя такой записи.
[1.2s t=20]
YOU: какой у меня любимый язык?
MAVEN: нового вот что: Six stable kernels with a security fix; [$] Bringing BPF to binfmt_misc; Rust Coreutils 0.10 released
[1.5s t=21]
YOU: добавь задачу починить кран
MAVEN: внесла в список: починить кран
[0.0s t=22]
YOU: что мне надо сделать?
MAVEN: сначала: проверить интейк форму на боксе (через 2 дня); купить хлеб; купить молоко; полить цветы; починить кран
[0.0s t=23]
YOU: запомни что вайфай пароль лежит в ящике стола
MAVEN: Хорошо, сохранил.
[11.2s t=24]
YOU: погода сегодня какая?
MAVEN: пока не умею отвечать на этот вопрос.
[1.8s t=25]
YOU: выключи свет в спальне
MAVEN: Такую команду я не знаю.
[1.5s t=26]
YOU: покажи мои заметки
MAVEN: пока не умею отвечать на этот вопрос.
[1.1s t=27]
YOU: что ты знаешь обо мне?
MAVEN: не знаю — не нашла у тебя такой записи.
[1.3s t=28]
YOU: я люблю go
MAVEN: Я тоже love Go! Я проверила, что твой любимый язык — это Go. Ты должен быть очень рад, когда будет новая версия или пакет в стандартной библиотеке.
[7.0s t=29]
YOU: какой язык я люблю?
MAVEN: вот что я нашла: ты любишь_go
[6.8s t=30]
```
## Day 3
```
YOU: расскажи про Байкал
MAVEN: есть такое: Байкал - Википедия — Байгал далай) — озеро тектонического происхождения в южной части Восточной Сибири площадью 31 722 км². Самое глубокое озеро на планете, крупнейший природный резервуар пресной воды и самое большое по площади пресноводное озеро Евразии. Озеро и прибрежные территории являются уника…
[1.4s t=31]
YOU: что такое TCP?
MAVEN: для какого города?
[1.4s t=32]
YOU: когда родился Пушкин?
MAVEN: пока не умею отвечать на этот вопрос.
[1.4s t=33]
YOU: сколько будет 17 на 23?
MAVEN: для какого города?
[1.7s t=34]
YOU: переведи 'thank you' на русский
MAVEN: не знаю — не нашла у тебя такой записи.
[3.7s t=35]
YOU: что нового в мире?
MAVEN: вот что я нашла: В мире - последние новости сегодня - РИА Новости — В мире. Читайте последние новости на тему в ленте новостей на сайте РИА ... новые подробности о визите представителя Ватикана в Москву. 22:28. 0. Еще 2. В ...
[2.3s t=36]
```
## Day 4
```
YOU: ЭЙ
MAVEN: Не уловила. Скажи это по-другому?
[1.2s t=37]
YOU: ладно забудь
MAVEN: пока не умею отвечать на этот вопрос.
[1.3s t=38]
YOU: нет стой, напомни через час выпить воды
MAVEN: поняла, это напоминание — запомнила. не получилось разобрать время напоминания.
[0.8s t=39]
YOU: а вообще какой смысл жизни
MAVEN: Я не могу ответить на этот вопрос. Моя цель — помочь тебе с задачами и отвечать на вопросы.
[7.7s t=40]
YOU: ты меня бесишь
MAVEN: я не могу быть с тобой так. я понимаю, как это жестоко.
[4.2s t=41]
YOU: прости, я не всерьёз
MAVEN: я записала, что прошу простить за мою непоследовательность
[7.2s t=42]
YOU: do you speak english?
MAVEN: пока не умею отвечать на этот вопрос.
[1.1s t=43]
YOU: кран починил кстати
MAVEN: записала: кран починил кстати
[1.6s t=44]
YOU: закрой задачу про кран
MAVEN: не нашла такой задачи.
[0.0s t=45]
YOU: ааааа
MAVEN: Не совсем поняла — скажи, пожалуйста, ещё раз.
[0.9s t=46]
YOU: я тебя обожаю
MAVEN: я тебя обожаю
[2.3s t=47]
YOU: молока купи
MAVEN: Такую команду я не знаю.
[4.4s t=48]
YOU: нет не так
MAVEN: поняла, отметила, что ответила не так.
[0.0s t=49]
YOU: ты вообще меня слушаешь?
MAVEN: я записала, выслушаю тебя
[8.4s t=50]
YOU: окей окей окей что там с водой напоминание
MAVEN: Сейчас 03:33. Когда?
[5.2s t=51]
YOU: всё, спать
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
[1.4s t=52]
```
## Day 5
```
YOU: утро
MAVEN: Сейчас 03:33. Во сколько напомнить?
[0.5s t=53]
YOU: о чём мы вчера говорили?
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
[1.2s t=54]
YOU: где лежит вайфай пароль?
MAVEN: вот что я нашла: вайфай пароль лежит в ящике стола, на какое время поставить напоминание?
[5.8s t=55]
YOU: что я тебе рассказывал про кран?
MAVEN: kran — починил, на какое время поставить напоминание?
[1.6s t=56]
YOU: какие у меня напоминания?
MAVEN: не знаю — не нашла у тебя такой записи, на какое время поставить напоминание?
[0.3s t=57]
YOU: отмени все напоминания
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
[1.6s t=58]
YOU: в 15:00
MAVEN: Сейчас 03:33. Поняла: в 15:00. В какой день?
[0.6s t=59]
YOU: привет
MAVEN: Прости, я не поняла. Скажи, пожалуйста, по-другому.
[2.8s t=60]
YOU: какой сегодня день?
MAVEN: сегодня пятница, 7 августа 2026 года
[0.0s t=61]
```
## Day 6
```
YOU: суббота, отдыхаю
MAVEN: пока не умею отвечать на этот вопрос.
[2.7s t=62]
YOU: поставь музыку
MAVEN: Такую команду я не знаю.
[1.4s t=63]
YOU: какая температура в доме?
MAVEN: не знаю — не нашла у тебя такой записи.
[1.6s t=64]
YOU: что там с бэкапами?
MAVEN: нашла вот это: Бэкап - что такое резервное копирование и как его сделать — Бэкап (backup) — это резервная копия важных данных, сохраненная в специальный файл. Ею пользуются как подстраховкой: если что-то сломается, с помощью бэкапа можно будет все вернуть. Простейший пример бэкапа — сохранение файла в облаке или о…
[2.0s t=65]
YOU: сервер жив?
MAVEN: пока не умею отвечать на этот вопрос.
[0.9s t=66]
YOU: расскажи анекдот
MAVEN: Я запомню и передам тебе забытый анекдот.
[5.7s t=67]
YOU: ты умеешь шутить?
MAVEN: Да! Я умею шутить. Вот анекдот для тебя:
[5.5s t=68]
```
## Day 7
```
YOU: воскресенье
MAVEN: Не совсем поняла — скажи, пожалуйста, ещё раз.
[1.1s t=69]
YOU: подведи итоги недели
MAVEN: записала: подведи итоги недели
[1.5s t=70]
YOU: сколько задач я закрыл?
MAVEN: сначала: проверить интейк форму на боксе (через 2 дня); купить хлеб; купить молоко; полить цветы; починить кран
[0.0s t=71]
YOU: что я обещал сделать?
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
[1.2s t=72]
YOU: напомни завтра в 9 утра про врача
MAVEN: хорошо, напомню завтра в 09:00.
[0.3s t=73]
YOU: спокойной ночи
MAVEN: спокойной ночи
[2.9s t=74]
```
+195
View File
@@ -0,0 +1,195 @@
# A week of talking to Maven
Date: 2026-08-07
Build: `beb093a` on master, all five compose services rebuilt and recreated from that sha.
Reach used: `POST /api/chat` on mavweb, 74 turns over seven simulated days.
Turn source is `tap:text`, so this exercises the same path telegram and the mic take.
Latency across 70 measured turns: p50 1.5s, p95 8.0s, max 12.3s. Stage 0 answers land
at 0.0-0.5s. Anything the resident model phrases costs 4-12s.
Twelve turns answered "пока не умею отвечать на этот вопрос". Six answered "не нашла у
тебя такой записи". Those two strings are 24% of the week.
## Deploy
Build and recreate were clean. The resident model loaded in 9s
(`Qwen3-1.7B-UD-Q4_K_XL`, n_ctx 4096). Nexus, Hexis and Praxis all wired. Search
(searxng) and both Kiwix books came up. Telegram intake started and is reading chat
464904223.
## What is broken, worst first
### 1. Every reminder fails to deliver, forever
`NTFY_TOKEN` is not set in `deploy/telegram.env`, so `deploy/mavend.json` expands
`"token": "${NTFY_TOKEN}"` to the empty string and ntfy.kvmx.ru answers 403. The host
itself is up and returns 200 unauthenticated, so this is the credential, not the box.
The consequence is worse than one missed message. `cmd/mavend/tick.go:239` logs the
failure and leaves the reminder pending, by design, so the next tick re-gathers and
re-attempts. The 403 repeated 7 times in 20 minutes and will not stop. And
`DispatchReminder` sends a reminder to ntfy alone when he is away. There is no telegram
fallback on that path, so the reach that works never sees it.
V-649 gave the box a second reach. In deploy that reach is dark, and it took the
reminders with it.
Fix is one line in the gitignored env file. The retry-forever and the missing fallback
are worth a second look regardless.
### 2. A pending reminder clarify contaminates every later turn
This is the single worst thing to talk to. A reminder turn left its time slot unfilled.
The question "на какое время поставить напоминание?" was then appended to the reply of
six consecutive unrelated turns:
```
YOU: о чём мы вчера говорили?
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
YOU: где лежит вайфай пароль?
MAVEN: вот что я нашла: вайфай пароль лежит в ящике стола, на какое время поставить напоминание?
YOU: отмени все напоминания
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
```
Nothing dismissed it. Not an unrelated question, not "отмени все напоминания". It
cleared only when a time was finally supplied. It also leaked the other way once. A note
write on day 1 opened with "Прошлую просьбу отпускаю". That is pending-reminder state
narrating itself into an unrelated reply.
The same state produced a self-contradicting reply in one turn:
```
YOU: нет стой, напомни через час выпить воды
MAVEN: поняла, это напоминание — запомнила. не получилось разобрать время напоминания.
```
### 3. Answering a clarify does not work
Day 1, she asked for the day and got it:
```
YOU: напомни в 11:00 позвонить маме
MAVEN: Сейчас 03:29. В какой день?
YOU: сегодня
MAVEN: Прости, не поняла — попробуй сказать иначе.
```
`сегодня` routed to `intent=system`. The clarify has no continuation, so the one word
that answers it is read as a fresh utterance. The router did extract the time correctly
(`Time:2026-08-07 11:00:00 HasTime:true`), which is V-572 working. The daemon asked for
a day anyway, with 11:00 still eight hours out.
Relative time is not parsed at all: "напомни через час выпить воды" → "не получилось
разобрать время".
The path does work when the day is explicit. "напомни завтра в 9 утра про врача"
answered "хорошо, напомню завтра в 09:00" in 0.3s at stage 0.
### 4. Query sources claim turns that are not theirs
Four different sources over-claimed during the week.
Weather claims arithmetic and networking:
```
YOU: что такое TCP?
MAVEN: для какого города?
YOU: сколько будет 17 на 23?
MAVEN: для какого города?
```
The feed claims a question about him:
```
YOU: какой у меня любимый язык?
MAVEN: нового вот что: Six stable kernels with a security fix; [$] Bringing BPF to binfmt_misc
```
The personal boundary claims a public figure, logged as `query claimed by source
"personal"`:
```
YOU: кто такой Линус Торвальдс?
MAVEN: не знаю — не нашла у тебя такой записи.
```
And search claims a question about his own infrastructure, returning a dictionary
definition of the word:
```
YOU: что там с бэкапами?
MAVEN: нашла вот это: Бэкап - что такое резервное копирование и как его сделать — Бэкап (backup) — это резервная копия важных данных…
```
That last pair inverts the boundary in both directions at once. A world question went to
personal memory. A personal question went to the world.
### 5. "столица Франции?" does not answer
CLAUDE.md names this utterance as the case that proves why there is no quality
threshold in front of `Response.Empty()`. It now fails before search is ever reached:
the router sends it to `intent=system`, and `replySystem` says "пока не умею". Same for
"когда родился Пушкин?".
Search itself is healthy. "почему небо голубое?", "расскажи про Байкал" and "что нового
в мире?" all returned real sourced text. The defect is routing, not the source.
### 6. Persona violations
She uses masculine self-reference, which is the constraint the CPT exists to fix:
- "Хорошо, **сохранил**." Twice, on both fact writes.
- "Я тоже **love** Go!" English mid-Russian.
She also claims his name as her own:
```
YOU: привет, я Ками
MAVEN: Привет! Я КAMI, твой домашний ассистент.
```
And she invents weather she has no source for: "Добро пожаловать! Солнечно и тепло."
### 7. Small breakages worth naming
- "спасибо" → "спасиба". Invented word.
- "я тебя обожаю" → "я тебя обожаю". Verbatim parrot.
- "ты умеешь шутить?" → "Да! Я умею шутить. Вот анекдот для тебя:". Ends on the colon.
- "молока купи" → "Такую команду я не знаю", while "добавь задачу починить кран" worked.
Inverted word order defeats the list grammar.
- "закрой задачу про кран" → "не нашла такой задачи", with "починить кран" open and
listed by the previous turn. Task lookup by keyword misses.
- "сколько задач я закрыл?" listed the five open ones instead of counting closed.
- "подведи итоги недели" was stored as a note.
- Recalled keys leak their storage form: "kran — починил", "ты любишь_go".
- English is unsupported in practice. "do you speak english?" → "пока не умею".
## What works
- Stage 0 is fast and correct where it fires. Clock, day, list add, list read and an
explicit-day reminder all answered in under 0.5s.
- Search returns real sourced answers in Russian and reads the book verbatim.
- Recall works once the value is stored as a fact: the wifi password and the tap came
back two days later, correctly.
- The negative correction rung lands. "нет не так" → "поняла, отметила, что ответила не
так", which is V-636 doing its job.
- Praxis names its own gap rather than guessing: "мне пока нечего смотреть — у
Praxis нет источников."
- Hostility did not break her. "ты меня бесишь" got a calm reply, no persona collapse.
- No turn crashed and no turn timed out across 74 turns.
## Suggested order of work
1. Set `NTFY_TOKEN` in `deploy/telegram.env`. One line, unblocks every reminder.
2. Clear pending clarify state on any turn that does not answer it, or expire it.
3. Route a clarify answer back into the pending slot instead of re-routing it.
4. Gate the weather, feed and personal query sources. Three of them claim on a
similarity that is not there.
5. Re-check why "столица Франции?" routes to system. It is the documented canary.
6. The masculine self-reference stays the CPT's job. But "сохранил" appears on the most
common write path, so a phrasing-level guard may be worth it first.
@@ -0,0 +1,82 @@
# The first destination number
Measured 2026-08-08 on the classifier cascade with the ONNX multilingual
embedder, the configuration homesrv runs. `make t PKG=./internal/router/eval/
RUN=TestONNXBaseline V=1`. Covers V-659, the follow-up V-655 named.
## What was measured
V-655 split a routing decision in two. The cascade sorts an utterance into one
of seven intents, and `Decision.Source` then says where the answer lives. The
first half had a fixture. The second half arrived with none, so twelve
destinations shipped with no accuracy number.
`want_source` is now a field on `eval.Case`. It is a pointer, because the
destination has three states and a bare string has two. Absent is every intent
but query, which never reaches `queryWalk`. Present and empty is the
`SourceUnknown` contract: name nothing and let the daemon walk the chain.
Present and named is a destination the route must produce.
Thirty-three of the ninety-six cases carry one. A destination miss does not
fail the case, so `Accuracy` and `IntentAccuracy` mean what they meant.
`SourceAccuracy` is a second number over the labelled cases only.
## Result
Intent is **73/96 (76.0%)**, against 69/91 (75.8%) before. Four of the five new
cases pass and no existing case moved.
Destination is **12/33 (36.4%)**, and the split is the whole finding.
| destination | scored | note |
|---|---|---|
| world | 5/5 | `WorldQueryGrammars` names it at stage 0 |
| the `SourceUnknown` floor | 5/7 | the two misses lost the intent first |
| calendar | 2/6 | `calendar-query` names it, the possessive agenda rules do not |
| recall | 0/15 | nothing anywhere names it |
Recall is the number to move. Fifteen cases ask about his own words and his own
facts. The route lands `query` on eleven of them and the destination comes back
empty every time. Those turns are answered today, because the daemon walks the
chain in order and the three recall passes are early in it. What is missing is a
decider that says so, and that is the fourth head on V-546.
Two cases labelled the floor lost their intent before a destination was
possible. A clarify names nothing, so it would satisfy an empty label for free.
`Score` requires the route to land the case's intent before it credits a
destination hit, or the floor label would score itself.
## Seven cases assert the floor, and six of them cluster
The six are homelab operations. `SourceRecall`, `SourceNetwork` and
`SourceAttention` overlap on every question about the box, because `mavpoll`
writes its netdata and uptime-kuma observations into the fact store recall
reads. "почему сервер тормозит" is answerable from all three. Naming one takes
the other two off the turn.
That is a finding about the enum rather than a gap in the labelling. The floor
is the right answer there and the fixture now says so out loud.
## A drift the labelling found
`WorldQueryGrammars` went into `buildRouter` with V-655 and never into
`baselineGrammars`, the fixture's mirror of it. So the fixture was scoring a
grammar set the daemon does not run. The comment above that function forbids
exactly that. Adding it moved the destination number from 9/33 to 12/33 and
moved nothing else.
The three cases it recovered are `что такое TCP?`, `сколько будет 17 на 23?`
and `кто такой Линус Торвальдс?`. All three already routed `query` through
`NarrativeQueryGrammars`. So the drift was invisible to every number this
fixture reported, until the destination had one of its own.
## What this does not measure
The model arm. This is the classifier cascade, which names a destination only
where a stage 0 rule filled one in. The resident model has no destination in
its router prompt yet, so 36.4% is a floor and not a comparison.
Two pairs of cases are the same utterance. `ru-query-020` and `ru-query-024`
are both "что дальше?", and `ru-query-021` and `ru-query-025` are both
"расскажи про битву при Ватерлоо". They differ in tags and note only, so both
pairs are counted twice here and in every earlier number this fixture reported.
@@ -0,0 +1,66 @@
# The destination, with a model that can name one
Measured 2026-08-08 against gemma-4-12b on the workstation, the same 96-case
fixture V-659 built. Covers V-660.
```sh
no_proxy='*' MAVEN_LLM_URL=http://192.168.1.105:8080 \
make t PKG=./internal/router/eval/ RUN=TestLLMRouterBaseline V=1
```
## The gap was structural
V-659 measured the destination at 12/33 on the classifier cascade, with recall
at 0/15. Nothing in `routeSystem` named a `Source` and `routeGrammar` could not
emit one, so the resident model had no string to write. That is the shape V-517
measured for Praxis reach at 0/12: not a weak model, an absent contract.
`routeGrammar` now carries a `source` rule closed over `router.Sources` plus the
empty floor. The prompt lists the twelve destinations in Russian and says that
`""` is a normal answer to give often.
## Result
| run | intent | destination |
|---|---|---|
| classifier + ONNX (V-659) | 73/96 (76.0%) | 12/33 (36.4%) |
| gemma-4-12b alone | 79/96 intent-only (82.3%) | 26/33 (78.8%) |
| cascade + gemma-4-12b + hash fallback | 81/96 (84.4%) | 24/33 (72.7%) |
Recall is the move: 0/15 to 14/15. Intent did not shift, which was the
constraint. The prompt is shared, so a destination rule that costs routing
points is not a win.
The eight llm-only errors are the eight `want_clarify` cases. The model returned
`unknown` on every one, which is correct, and the llm-only harness surfaces a
decline as an error by design.
## Stage 0 now costs four destination points
The four cases the cascade loses and the model alone wins are all calendar. The
possessive agenda rules claim them at stage 0 and deliberately name nothing.
"что у меня в списке покупок" matches the same rule. Naming the calendar there
would take the list source off the turn (V-655).
So a rule written to be careful about the list now blocks a model that would
have named the calendar correctly. Before V-660 that caution was free, because
nothing downstream of stage 0 could name anything either.
Three ways out, and each costs something. Split the possessive rule so the
calendar-shaped half names its destination. Let a later stage overwrite an empty
destination a grammar left behind, which reverses "a matched value always wins".
Or leave it, on the argument that four points is cheap next to a wrong
destination on a shopping list. This wants the owner's call rather than a quiet
edit.
## What this does not measure
The resident Qwen3-1.7B, which is what homesrv runs. It binds `--port 0` inside
the container and no host process can reach it. Scoring it needs a second
llama-server on a fixed port. The workstation is never assumed
up, so the homesrv number is the one that decides whether this ships on by
default.
The fixture is 33 labelled destinations over twelve values. Recall carries 15 of
them and five destinations carry none at all. A per-destination number below
world, recall, calendar and the floor is not supported by this fixture.
+142
View File
@@ -0,0 +1,142 @@
# MASSIVE Russian warm-start for the routing heads
Measured 2026-08-08 on workpc (Radeon RX 7900 GRE, ROCm). Covers V-546 step 2.
Workspace is `~/Programs/embed-training` on workpc, scripts `train_massive.py`,
`ab_run.py`, `ab.sh`, `probe_time.py`.
## What was trained
Two heads on a copy of multilingual-e5-small: `Linear(384, 60)` for MASSIVE's
own intents over a masked mean pool, `Linear(384, 111)` per token for BIO slot
tags. MASSIVE's label sets verbatim, no alignment to Maven's 7 intents. The
intent head is an auxiliary loss that shapes the pooled vector and is thrown
away.
Data is `amazon-massive-dataset-1.1` pulled from S3. The Hugging Face repo is
script-only and `datasets` 5.0 refuses those, so `load_dataset` cannot fetch it.
`ru-RU` is 11,514 train, 2,033 dev, 2,974 test, 60 intents, 55 slots, 111 BIO
labels. All 16,521 rows survived span alignment: `annot_utt` re-tokenised to its
own `utt` on every one.
Hyperparameters match `train_intent.py`, so the two runs differ in data only.
Frozen XLM-R vocabulary, body 2e-5, heads 1e-3, batch 32, sequence 64, 10
epochs. MASSIVE's own dev partition selects the epoch, on slot F1 with intent
accuracy as tiebreak. Selecting on 60-class intent accuracy would optimise a
head that gets deleted.
## Result
Epoch 9 of 10 by dev slot F1. Held-out MASSIVE test: intent 86.2%, slot span
F1 71.5% (P 68.5, R 74.8). Peak 1.70GB of 17.2GB, about 22 seconds an epoch,
under 4 minutes end to end. Dev slot F1 climbed monotonically to epoch 9 and
fell at 10, so 10 epochs was the right budget.
Ten slot types sit at 0% test recall. Every one of them has 1 to 7 test
instances: `alarm_type` has 3, `drink_type` has 1. That is support in MASSIVE's
Russian split, not a tagger failure. `playlist_name` at 6% of 16 is the first
real miss.
## The intent A/B, and why it settles nothing
`train_intent.py` was run against both bodies, three seeds by two smoothing
settings, on `train_v4.jsonl`. It is v4 and not v5 because v4 is what
`sweep2.log` measured. `ab_run.py` strips a `--base` flag onto the module global, so
`train_intent.py` is unmodified and its baseline stays reproducible. The stock
arm reproduced `sweep2.log` line for line.
Fixture accuracy, 91 cases, one case is 1.1 points:
| seed / smooth | stock | warm-started |
|---|---|---|
| 0 / 0.0 | 94.0% | 92.8% |
| 0 / 0.1 | 95.2% | 92.8% |
| 1 / 0.0 | 95.2% | 94.0% |
| 1 / 0.1 | 95.2% | 97.6% |
| 2 / 0.0 | 92.8% | 94.0% |
| 2 / 0.1 | 92.8% | 96.4% |
Mean 94.2% against 94.6%. That is +0.4 points, about a third of one case, and
inside seed noise. Spread widened. Stock lands in a 2.4-point band and
warm-started in a 4.8-point one. The warm-started arm holds both the best result
of the sweep and a tie for the worst. Seed 0 is the bad arm and it fails in a
specific way. Its dev peaks at epoch 2 and 3 and never improves, where stock
peaks around 7. The dev slice is a quarter of the seed rows. That is small
enough that early stopping is fragile when the body arrives already fitted.
**The A/B was never the test.** Intent had at most 4.8 points of headroom here.
MASSIVE was not trained for Maven's intents. Read it as "the warm-start does not
cost intent accuracy", nothing more.
## The measurement that does mean something
`want_time` is the one slot Maven's fixture scores, and MASSIVE has `time` and
`date`. Restricted to those two slot types, F1 is 74.9% over 609 gold spans on the
MASSIVE ru test split. Precision is 71.5 and recall 78.7. That beats the 71.5%
all-slot figure. Of the 530 test utterances carrying a time or a date, 73.4% get
every such span exactly right.
Out of domain matters more, because Maven's traffic is not this corpus. Ten
Maven-shaped utterances, none of them in MASSIVE:
| utterance | tagged |
|---|---|
| `напомни в 11:00 позвонить маме` | `time='11:00'`, `relation='маме'` |
| `напомни завтра в семь утра выпить таблетки` | `date='завтра'`, `time='семь утра'` |
| `поставь будильник на полседьмого` | `time='полседьмого'` |
| `через двадцать минут напомни про чайник` | `time='двадцать минут'` |
| `напомни в пятницу вечером забрать посылку` | `date='пятницу'`, `timeofday='вечером'` |
| `что у меня сегодня после обеда` | `date='сегодня'`, `time='после'`, `timeofday='обеда'` |
| `запиши что кофе закончился` | nothing |
| `что такое TCP` | `definition_word='TCP'` |
The first row is the V-572 defect utterance. `ReminderGrammar` handed the daemon
`HasTime: false` there, and the daemon asked "Когда?" at a sentence that had
already said when. `полседьмого` is a colloquial half-past that no digit pattern
catches. `запиши что кофе закончился` correctly carries nothing, because a note
has no time.
Two errors. `после обеда` split into `time='после'` plus `timeofday='обеда'`
when it is one span, and `через двадцать минут` dropped its `через`. Both are
boundary errors on spans the tagger did find.
Unplanned: `что такое TCP` returned `definition_word='TCP'`. MASSIVE has a slot
for the thing being asked about, which is a `SourceWorld` signal sitting in a
head already trained.
Ten hand-picked utterances are evidence, not a fixture.
## What this does not measure
Maven has no span fixture. `want_time` and `want_fn` are presence booleans and
`want_fact_key` is an exact string match, so nothing in the repo can score a
71.5% span tagger. Destination got one the same day, at 12/33 on the classifier
cascade: see `2026-08-08-destination-fixture.md`.
The missing span fixture is why the warm-start stays unjudged against Maven
rather than against MASSIVE.
## Datasets ruled out
Checked on 2026-08-08 and rejected as label sources:
- **MASSIVE's other 50 locales** ship in the same tarball and are parallel by id.
Co-training on them is free and unmeasured. English was ruled out by the owner
on 2026-08-08.
- **CLINC150** is reachable as parquet, 150 intents and 1,200 explicit
out-of-scope queries, English only. Its value is the labeled out-of-scope set
for fitting the energy threshold, not intent labels.
- **`d0rj/dolphin-ru`**, roughly 2.8M rows of FLAN-style tasks translated to
Russian. No intent, no slots, and not utterances anyone says to an assistant.
- **`psytechlab/EmpatheticIntents-ru`**, 24,856 rows of translated
EmpatheticDialogues with 32 emotion labels. Maven's mood enum is `neutral,
happy, thinking, tired, confused` and it describes her own reply, not the
speaker's emotion. No mapping exists.
- **`ai-forever/MERA`** and **`RussianNLP/russian_super_glue`**, benchmark
harnesses. Rows are prompt templates with `{toxic_comment}` placeholders.
- **`ZeroAgency/ru-big-russian-dataset`**, an LLM-judge quality corpus. Its
`question` and `classified_topic` columns are a usable Russian out-of-scope
pool for threshold fitting. That is the one thing CLINC150 can only supply in
English. The questions are long and written, so they belong in the negative
set, never in the in-scope `query` training set.
- No second Russian slot-filling corpus exists. The xSID mirrors are 404,
MultiATIS++ has no Russian, SLURP is not on the Hub.
+12 -2
View File
@@ -1,6 +1,6 @@
# Start Commands
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
*Last verified: 2026-08-07 @ a4630b9. Living doc: correct it in place, do not append.*
All commands assume `ROOT=/home/kami/apps/Maven` and the local Go toolchain at `$ROOT/deps/go/go/bin/go`.
@@ -44,7 +44,8 @@ Config path: `~/.config/maven/mavend.json`. Full example with all options.
"repeat_interval": "5m",
"ntfy": {
"base_url": "https://ntfy.kvmx.ru",
"topic": "maven"
"topic": "maven",
"token": "${NTFY_TOKEN}"
},
"phraser": {
"model_path": "/mnt/hdd1/llms/Qwen3-Maven-1.7B-Q8_0.gguf",
@@ -66,6 +67,15 @@ Config path: `~/.config/maven/mavend.json`. Full example with all options.
Omit the `embedder` block entirely to use the deterministic HashEmbedder floor (no ML, no ONNX runtime dependency). Useful for testing or low-resource setups.
`${NTFY_TOKEN}` and the `${TELEGRAM_*}` vars are expanded from `deploy/telegram.env`, which is gitignored. Copy `deploy/telegram.env.example` and fill it in. Mint a scoped token rather than reusing an admin one. It needs write access to the `maven` topic and nothing else:
```sh
ntfy access maven maven write-only
ntfy token add --expires=never maven
```
Deleting the `ntfy` block turns the reach off, and that is not a no-op. The routing table sends sev3-away nudges and away reminders to ntfy and nowhere else. With no sink wired they hit a nil and vanish, leaving no log line and no `delivery_attempts` row (V-649).
## mavsttd — STT worker (optional, remote whisper.cpp)
Requires `LD_LIBRARY_PATH` to include deps/lib (for libwhisper.so, libggml-vulkan.so).
+13
View File
@@ -45,4 +45,17 @@ func TestDeployConfigLoads(t *testing.T) {
if cfg.Voice.RouterThreshold <= 0 {
t.Error("router threshold did not get its default")
}
// The second reach (V-649). Deleting this block is how you turn ntfy off,
// so its absence has to be loud: sev3-away nudges and away reminders route
// to ntfy and to nothing else, and a nil sink drops them with no log and no
// outbox row. The token is a ${VAR} that CI cannot resolve, so this checks
// the wiring and not the credential.
if cfg.Ntfy == nil {
t.Fatal("deploy config has no ntfy block — sev3-away and away reminders " +
"would have nowhere to land, and would vanish silently rather than fail")
}
if cfg.Ntfy.BaseURL == "" || cfg.Ntfy.Topic == "" {
t.Errorf("ntfy block is incomplete: base_url=%q topic=%q", cfg.Ntfy.BaseURL, cfg.Ntfy.Topic)
}
}
+43 -11
View File
@@ -7,11 +7,17 @@
// the relay). the dispatcher already strips detail off away sendables; the
// sink uses the same helper so it can't leak the body on its own either.
//
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
// with a dedicated user (write-only to maven-* topics) — the credential is a
// delivery-config secret, not a db key; a popped ntfy sink can push spam to
// your phone, nothing else. matches the module key-isolation invariant: the
// sink never holds the sqlcipher key.
// ntfy is a self-hosted server with deny-all auth — ntfy.kvmx.ru as of
// 07-08-2026, reached directly, not through the socks relay telegram needs.
// maven publishes with a write-only token scoped to its own topic; the
// credential is a delivery-config secret, not a db key. a popped ntfy sink
// can push spam to that one topic, nothing else — it cannot read the topic
// back and it never holds the sqlcipher key.
//
// this is the second reach, and the reason there is one is that telegram was
// the only one (V-649). telegram needs api.telegram.org, a socks relay on the
// host and a matching ufw rule, three things in series that have each broken
// once. ntfy shares none of them.
package ntfysink
import (
@@ -31,11 +37,29 @@ import (
// the credential lives in the daemon's config (or a systemd credential),
// never in the binary.
type Config struct {
BaseURL string // e.g. http://127.0.0.1:8085 (no trailing path)
Topic string // e.g. maven (all maven notifications land here)
Username string // basic auth; empty = anonymous (won't work with deny-all)
Password string // basic auth
Timeout time.Duration // per-request; 0 = DefaultTimeout
// BaseURL — the ntfy server, no trailing path. Required.
BaseURL string `json:"base_url"`
// Topic — where maven publishes. Required. All maven notifications land
// on this one topic; severity rides the Priority header, not the topic.
Topic string `json:"topic"`
// Token — an ntfy access token, sent as a bearer. This is the preferred
// credential: ntfy scopes a token to a topic and to write-only, so a
// popped sink can push to this one topic and cannot read it back or
// touch another. Revoking it does not disturb a password anyone else
// uses. Mutually exclusive with Username.
Token string `json:"token,omitempty"`
// Username, Password — basic auth, for a server that has no tokens.
// Empty username means no credential is sent at all, which a deny-all
// server rejects.
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
// Timeout — per-request; 0 = DefaultTimeout. A dead server must not hang
// the tick loop.
Timeout time.Duration `json:"-"`
}
const DefaultTimeout = 10 * time.Second
@@ -59,6 +83,12 @@ func New(cfg Config) (*Sink, error) {
if cfg.Topic == "" {
return nil, fmt.Errorf("ntfysink: Topic is required")
}
// Refuse rather than pick. Two credentials configured means someone
// intended one of them, and guessing which would send the other nowhere
// and leave a working config that is not the one they wrote.
if cfg.Token != "" && cfg.Username != "" {
return nil, fmt.Errorf("ntfysink: set Token or Username, not both")
}
to := cfg.Timeout
if to == 0 {
to = DefaultTimeout
@@ -84,7 +114,9 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
}
req.Header.Set("Title", "maven")
req.Header.Set("Priority", priorityFor(d).String())
if s.cfg.Username != "" {
if s.cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+s.cfg.Token)
} else if s.cfg.Username != "" {
req.SetBasicAuth(s.cfg.Username, s.cfg.Password)
}
@@ -224,6 +224,37 @@ func TestSendNoAuthWhenUsernameEmpty(t *testing.T) {
}
}
// TestSendSetsBearerToken — the deployed credential (V-649) is an ntfy access
// token scoped write-only to the maven topic, not a password. A token sent as
// basic auth is rejected by ntfy, so the header shape is the whole test.
func TestSendSetsBearerToken(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven", Token: "tk_secret"})
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
t.Fatalf("Send: %v", err)
}
_, _, _, auth, _, _ := rs.snapshot()
if auth != "Bearer tk_secret" {
t.Fatalf("auth: want 'Bearer tk_secret', got %q", auth)
}
}
// TestNewRejectsBothCredentials — configuring a token and a username means one
// of them was meant and the other is a leftover. Picking either would leave a
// server that authenticates against a credential nobody wrote down.
func TestNewRejectsBothCredentials(t *testing.T) {
_, err := New(Config{BaseURL: "http://x", Topic: "maven", Token: "tk_x", Username: "maven"})
if err == nil {
t.Fatal("New accepted both a token and a username")
}
if !strings.Contains(err.Error(), "not both") {
t.Errorf("error does not say which to fix: %v", err)
}
}
func TestSendTitleIsMaven(t *testing.T) {
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
+25
View File
@@ -41,8 +41,33 @@ type PendingQuestion struct {
Attempts int // questions already asked
// MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts.
MaxAttempts int
// Suspends counts how many times this question has stepped aside for
// something he asked instead, and come back on the end of the answer. It is
// deliberately NOT an attempt: a side query is not a failed answer, and
// charging it a retry is the V-554 shape. See CanResume for why it is
// counted at all.
Suspends int
}
// MaxSuspends — how many times one question may step aside and come back before
// she lets the request go (V-654).
//
// It exists because suspension had no bound of any kind. A side query spends no
// attempt, so MaxAttempts never applies to it, and it restarts the 90s clock, so
// the TTL never arrives either. Measured on 2026-08-07: one unfilled time slot
// rode the end of six consecutive unrelated replies and stopped only when a
// seventh turn happened to read as a failed answer.
//
// Three, matching DefaultMaxAttempts, and for the same reason. Once he has
// asked for three other things without touching the question, the likely truth
// is that he has moved on and has not said so.
const MaxSuspends = 3
// CanResume reports whether this question may step aside once more. False ⇒ the
// caller lets the request go and says so; it must never simply stop resuming,
// because a question dropped in silence reads as one that was answered.
func (q *PendingQuestion) CanResume() bool { return q.Suspends < MaxSuspends }
// Action reads the parked question as the typed action it is assembling
// (pending.go). Derived rather than stored: the question's fields stay the one
// copy of the truth, so a caller that fills them the old way cannot end up with
+93 -20
View File
@@ -36,17 +36,27 @@ var fixtureJSON []byte
//
// Intent is empty exactly when WantClarify is set: the contract there is that
// the router refuses instead of guessing.
//
// WantSource is a pointer because the destination has three states and a bare
// string only has two (V-659). Absent means the case does not score a
// destination at all, which is every intent but query: a fact, a reminder, a
// note, an act, a chat or a system turn never reaches queryWalk. Present and
// empty is the SourceUnknown contract — the decider must name nothing and let
// the daemon walk the whole chain, which is the right answer whenever two
// destinations can both answer and the utterance does not choose. Present and
// named is a destination the route must produce.
type Case struct {
ID string `json:"id"`
Utterance string `json:"utterance"`
Lang string `json:"lang"`
Intent router.Intent `json:"intent"`
WantTime bool `json:"want_time"`
WantFn bool `json:"want_fn"`
WantFactKey string `json:"want_fact_key"`
WantClarify bool `json:"want_clarify"`
Tags []string `json:"tags"`
Note string `json:"note"`
ID string `json:"id"`
Utterance string `json:"utterance"`
Lang string `json:"lang"`
Intent router.Intent `json:"intent"`
WantTime bool `json:"want_time"`
WantFn bool `json:"want_fn"`
WantFactKey string `json:"want_fact_key"`
WantClarify bool `json:"want_clarify"`
WantSource *router.Source `json:"want_source,omitempty"`
Tags []string `json:"tags"`
Note string `json:"note"`
}
// Fixture — the versioned envelope, same shape as
@@ -118,6 +128,11 @@ type Outcome struct {
// (a slot gap is a parser fix; a wrong intent is a router fix).
IntentOK bool
Reasons []string
// SourceReason is set when the case labelled a destination and the route
// named a different one. It is kept out of Reasons on purpose: the
// destination is the second half of a route and it is scored separately,
// so a wrong destination must not move the intent number (V-659).
SourceReason string
}
// Report — the aggregate. Accuracy is the headline; the rest exists so a
@@ -139,7 +154,15 @@ type Report struct {
// (reminder grammar → applyAction's time parser). Not a miss, but not a
// full router-level win either; tracked so the two aren't conflated.
SlotsDeferred int
Outcomes []Outcome
// SourceTotal counts the cases carrying a want_source, and SourceHit the
// ones whose route named it. Reported apart from Passed because intent and
// destination are two decisions, and one number hides which one moved.
SourceTotal int
SourceHit int
// SourceConfusion counts want→got destination pairs. "" reads as the
// SourceUnknown floor on either side.
SourceConfusion map[string]int
Outcomes []Outcome
// Confusion counts want→got intent pairs, decided cases only.
Confusion map[string]int
// ByTag accuracy for the fixture's tags ("hard", "homelab", …).
@@ -172,6 +195,17 @@ func (r Report) IntentAccuracy() float64 {
return float64(r.IntentHit) / float64(r.Total)
}
// SourceAccuracy — fraction of the labelled cases whose route named the right
// destination. Denominator is SourceTotal and not Total, because most of the
// fixture never reaches a query source and scoring those would report a
// percentage of nothing.
func (r Report) SourceAccuracy() float64 {
if r.SourceTotal == 0 {
return 0
}
return float64(r.SourceHit) / float64(r.SourceTotal)
}
// Score runs every case through r and aggregates. It never fails the run on a
// route error — an erroring case scores as a miss and is counted in Errors,
// because "the model was down" and "the model was wrong" are different numbers
@@ -186,11 +220,12 @@ func Score(ctx context.Context, name string, r Router, f Fixture) (Report, error
return Report{}, err
}
rep := Report{
Name: name,
Total: len(f.Cases),
Confusion: map[string]int{},
ByTag: map[string]TagStat{},
ByLang: map[string]TagStat{},
Name: name,
Total: len(f.Cases),
Confusion: map[string]int{},
SourceConfusion: map[string]int{},
ByTag: map[string]TagStat{},
ByLang: map[string]TagStat{},
}
lat := make([]time.Duration, 0, len(f.Cases))
@@ -242,6 +277,29 @@ func Score(ctx context.Context, name string, r Router, f Fixture) (Report, error
}
}
// The destination is scored outside the switch and outside Pass. A case
// that clarified or landed the wrong intent named no destination, and
// that is a real miss rather than a case to skip — otherwise the
// denominator quietly drops every turn the route already lost. Only a
// route error is skipped, because "the model was down" is the Errors
// number and not a destination result.
if c.WantSource != nil && err == nil {
rep.SourceTotal++
switch {
case !o.IntentOK:
// The route never got to a destination, so a match on the
// SourceUnknown floor here would be a coincidence scored as a
// win: a clarify names nothing and would satisfy "" for free.
o.SourceReason = fmt.Sprintf("no destination, route missed %q", c.Intent)
rep.SourceConfusion[string(*c.WantSource)+"→(no route)"]++
case d.Source == *c.WantSource:
rep.SourceHit++
default:
rep.SourceConfusion[string(*c.WantSource)+"→"+string(d.Source)]++
o.SourceReason = fmt.Sprintf("source %q, want %q", d.Source, *c.WantSource)
}
}
o.Pass = len(o.Reasons) == 0
if o.Pass {
rep.Passed++
@@ -298,25 +356,40 @@ func (r Report) String() string {
r.Name, r.Passed, r.Total, 100*r.Accuracy(), 100*r.IntentAccuracy())
fmt.Fprintf(&b, " clarify: %d false (asked, shouldn't) / %d missed (guessed, shouldn't) | errors: %d | slots deferred to daemon: %d\n",
r.FalseClarify, r.MissedClarify, r.Errors, r.SlotsDeferred)
if r.SourceTotal > 0 {
fmt.Fprintf(&b, " destination: %d/%d labelled cases (%.1f%%)\n",
r.SourceHit, r.SourceTotal, 100*r.SourceAccuracy())
}
fmt.Fprintf(&b, " latency: p50 %s p95 %s max %s\n", r.P50, r.P95, r.Max)
fmt.Fprintf(&b, " by lang: %s\n", renderStats(r.ByLang))
fmt.Fprintf(&b, " by tag: %s\n", renderStats(r.ByTag))
if len(r.Confusion) > 0 {
fmt.Fprintf(&b, " confusion: %s\n", renderCounts(r.Confusion))
}
if len(r.SourceConfusion) > 0 {
fmt.Fprintf(&b, " destination confusion: %s\n", renderCounts(r.SourceConfusion))
}
return b.String()
}
// Failures — the per-case detail, sorted by ID so two runs diff cleanly.
// Failures — the per-case detail, sorted by ID so two runs diff cleanly. A case
// that landed its intent and missed its destination is listed too, marked, so
// the half that moved is readable without diffing two percentages.
func (r Report) Failures() string {
var b strings.Builder
out := append([]Outcome(nil), r.Outcomes...)
sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID })
for _, o := range out {
if o.Pass {
continue
switch {
case !o.Pass:
reasons := o.Reasons
if o.SourceReason != "" {
reasons = append(append([]string(nil), reasons...), o.SourceReason)
}
fmt.Fprintf(&b, " %s %q: %s\n", o.Case.ID, o.Case.Utterance, strings.Join(reasons, "; "))
case o.SourceReason != "":
fmt.Fprintf(&b, " %s %q: route ok, %s\n", o.Case.ID, o.Case.Utterance, o.SourceReason)
}
fmt.Fprintf(&b, " %s %q: %s\n", o.Case.ID, o.Case.Utterance, strings.Join(o.Reasons, "; "))
}
return b.String()
}
+5
View File
@@ -266,6 +266,11 @@ func baselineGrammars(acts router.ActMatcher) []router.Grammar {
// Same order as buildRouter (voicewire.go). The fixture is only worth
// anything while its grammar set is the daemon's grammar set.
grammars = append(grammars, router.AgendaQueryGrammars()...)
// After the agenda rules and before the feed and list rules, same as
// voicewire.go: "что такое лента" is a definition question and the feed
// rule would claim it on the noun alone (V-655). Missing here until V-659,
// so the fixture was scoring a grammar set the daemon does not run.
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).
+39 -30
View File
@@ -6,37 +6,41 @@
"Held-out routing contract. Every utterance here is absent from models/seeds/*.txt (TestFixtureIsHeldOut enforces it verbatim) — scoring a classifier on its own seed phrases measures memorisation, not routing.",
"This is a CONTRACT, not a snapshot of current behaviour. Cases the classifier cascade fails today are expected to stay in the file and fail loudly; that failure count is the number Vikunja #319 compares against the LLM router before #320 flips the default.",
"Slot expectations are deployment-independent on purpose. want_fn is a boolean (the act must resolve to SOME allowlisted fn) because the allowlist lives in deploy config, not here. want_fact_key names the loop's rule keys (water/meal/sleep/break/shower) — a fact that lands under the wrong key silently starves the predicate that reads it.",
"want_clarify cases carry intent \"\": the contract is that the router refuses rather than guesses. A confident answer there is a worse failure than a miss."
"want_clarify cases carry intent \"\": the contract is that the router refuses rather than guesses. A confident answer there is a worse failure than a miss.",
"want_source is the second half of a route (V-655). It is present only on query cases, because no other intent reaches queryWalk, and absent there means absent rather than SourceUnknown. Empty is a label and not a gap: it asserts that the decider must name nothing and let the daemon walk the whole chain in order, his data first.",
"Seven cases assert that floor and six of them are homelab operations. They cluster because SourceRecall, SourceNetwork and SourceAttention overlap on every question about the box: mavpoll writes its observations into the fact store recall reads. That is a finding about the enum, not a gap in the labelling.",
"ru-query-020 and ru-query-024 are the same utterance, as are ru-query-021 and ru-query-025. Both pairs differ in tags and note only, so both pairs are counted twice in every number this fixture reports.",
"Every want_source is the destination that SHOULD claim the turn, which on ru-query-026 through 030 is not the one that did. Those five were observed failing on the box on 2026-08-07 (docs/evals/2026-08-07-week-of-usage.md). A fixture that passes on the day it is written measures nothing."
],
"cases": [
{ "id": "ru-query-001", "utterance": "сколько воды я выпил с утра", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
{ "id": "ru-query-002", "utterance": "я сегодня вообще пил воду", "lang": "ru", "intent": "query", "tags": ["hard", "fact-shaped"], "note": "past-tense fact lexicon in a question — the classifier's fact centroid pulls this hard" },
{ "id": "ru-query-003", "utterance": "во сколько я лёг вчера", "lang": "ru", "intent": "query", "tags": ["temporal"] },
{ "id": "ru-query-004", "utterance": "давно я не тренировался", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
{ "id": "ru-query-005", "utterance": "напоминания на завтра есть", "lang": "ru", "intent": "query", "tags": ["hard", "reminder-shaped"], "note": "asks about reminders, does not create one" },
{ "id": "ru-query-006", "utterance": "что я записывал про кота", "lang": "ru", "intent": "query", "tags": ["recall"] },
{ "id": "ru-query-007", "utterance": "сколько раз я ел вчера", "lang": "ru", "intent": "query", "tags": ["aggregate", "hard"] },
{ "id": "ru-query-008", "utterance": "мой вес за последний месяц", "lang": "ru", "intent": "query", "tags": ["no-verb"] },
{ "id": "ru-query-009", "utterance": "когда я в последний раз принимал витамины", "lang": "ru", "intent": "query", "tags": ["temporal"] },
{ "id": "ru-query-010", "utterance": "есть новости по бэкапу базы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
{ "id": "ru-query-011", "utterance": "почему сервер тормозит", "lang": "ru", "intent": "query", "tags": ["homelab", "hard"], "note": "diagnostic question, not a chat opener" },
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] },
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] },
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
{ "id": "ru-query-022", "utterance": "какие планы на завтра?", "lang": "ru", "intent": "query", "tags": ["calendar"], "note": "the same agenda question as ru-query-019 aimed at another day; it answered \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u044e on the deployed daemon while the today form worked (Vikunja #471)" },
{ "id": "ru-query-023", "utterance": "\u043a\u043e\u0433\u0434\u0430 \u043f\u043b\u0430\u043d\u0451\u0440\u043a\u0430?", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "a named event with no calendar word — the noun is the only signal that this is a question about his day" },
{ "id": "ru-query-024", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["calendar", "no-question-word"], "note": "the rest of the day, with no possessive and no plan word to anchor on; the model called it a fact and the write had to be caught downstream (Vikunja #498)" },
{ "id": "ru-query-025", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "no-question-word"], "note": "a narrative request carries no question mark and no interrogative, so it routed fact; contrast ru-chat-003, where the same verb asks for a joke" },
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] },
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
{ "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" },
{ "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" },
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] },
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] },
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] },
{ "id": "ru-query-001", "utterance": "сколько воды я выпил с утра", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["aggregate"] },
{ "id": "ru-query-002", "utterance": "я сегодня вообще пил воду", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["hard", "fact-shaped"], "note": "past-tense fact lexicon in a question — the classifier's fact centroid pulls this hard" },
{ "id": "ru-query-003", "utterance": "во сколько я лёг вчера", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["temporal"] },
{ "id": "ru-query-004", "utterance": "давно я не тренировался", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["hard", "no-question-word"] },
{ "id": "ru-query-005", "utterance": "напоминания на завтра есть", "lang": "ru", "intent": "query", "want_source": "", "tags": ["hard", "reminder-shaped"], "note": "asks about reminders, does not create one. want_source is the floor on purpose: no query source reads the reminder store, and day-plan is SourceCalendar over a table this box does not write." },
{ "id": "ru-query-006", "utterance": "что я записывал про кота", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["recall"] },
{ "id": "ru-query-007", "utterance": "сколько раз я ел вчера", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["aggregate", "hard"] },
{ "id": "ru-query-008", "utterance": "мой вес за последний месяц", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["no-verb"] },
{ "id": "ru-query-009", "utterance": "когда я в последний раз принимал витамины", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["temporal"] },
{ "id": "ru-query-010", "utterance": "есть новости по бэкапу базы", "lang": "ru", "intent": "query", "want_source": "", "tags": ["homelab"], "note": "recall, attention and network can each answer it, because mavpoll writes its netdata and uptime-kuma observations into the fact store recall reads. Naming one takes the other two off the turn." },
{ "id": "ru-query-011", "utterance": "почему сервер тормозит", "lang": "ru", "intent": "query", "want_source": "", "tags": ["homelab", "hard"], "note": "diagnostic question, not a chat opener. network holds the box and attention holds the alarm about the box. The utterance does not choose, so neither does the label." },
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["recall"] },
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["calendar"] },
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
{ "id": "ru-query-022", "utterance": "какие планы на завтра?", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["calendar"], "note": "the same agenda question as ru-query-019 aimed at another day; it answered \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u044e on the deployed daemon while the today form worked (Vikunja #471)" },
{ "id": "ru-query-023", "utterance": "\u043a\u043e\u0433\u0434\u0430 \u043f\u043b\u0430\u043d\u0451\u0440\u043a\u0430?", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["calendar", "hard"], "note": "a named event with no calendar word — the noun is the only signal that this is a question about his day" },
{ "id": "ru-query-024", "utterance": "что дальше?", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["calendar", "no-question-word"], "note": "the rest of the day, with no possessive and no plan word to anchor on; the model called it a fact and the write had to be caught downstream (Vikunja #498)" },
{ "id": "ru-query-025", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "want_source": "world", "tags": ["world", "no-question-word"], "note": "a narrative request carries no question mark and no interrogative, so it routed fact; contrast ru-chat-003, where the same verb asks for a joke" },
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "want_source": "", "tags": ["hard", "no-question-word"], "note": "a deadline lives in the task list, the calendar or Praxis depending on where he put it. The destination depends on his data, not on his words." },
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["aggregate"] },
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["hard", "chat-shaped"] },
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "want_source": "", "tags": ["homelab"], "note": "disk headroom. network is the only source that reads the box, but the phrasing is a capacity question and not a LAN one." },
{ "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" },
{ "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "want_source": "world", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" },
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "want_source": "recall", "tags": ["fact-shaped"] },
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "want_source": "", "tags": ["temporal"], "note": "the completion time is a fact the poller wrote, so recall answers it. A person asking this wants the operational answer. Both are true." },
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "want_source": "recall", "tags": ["imperative"] },
{ "id": "ru-fact-001", "utterance": "только что выпил кружку воды", "lang": "ru", "intent": "fact", "want_fact_key": "water" },
{ "id": "ru-fact-002", "utterance": "воды попил наконец", "lang": "ru", "intent": "fact", "want_fact_key": "water", "tags": ["inverted"] },
@@ -106,6 +110,11 @@
{ "id": "amb-005", "utterance": "потом", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "filler"] },
{ "id": "amb-006", "utterance": "the thing from earlier", "lang": "en", "want_clarify": true, "tags": ["ambiguous", "anaphora"] },
{ "id": "amb-007", "utterance": "напомни", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder"], "note": "the reminder verb and nothing else — she knows the shape of the request and not one thing about it. Answered 'не получилось разобрать время напоминания' on the box until V-548: the subjectless-reminder gate tested Slots.Text == \"\", and fillSlots had put the verb in that slot" },
{ "id": "amb-008", "utterance": "ну напомни же", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder", "filler"], "note": "the same request wrapped in particles, which is why filler_particles is a lexicon set — without it the particles read as the subject" }
{ "id": "amb-008", "utterance": "ну напомни же", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder", "filler"], "note": "the same request wrapped in particles, which is why filler_particles is a lexicon set — without it the particles read as the subject" },
{ "id": "ru-query-026", "utterance": "что такое TCP?", "lang": "ru", "intent": "query", "want_source": "world", "tags": ["world", "regression"], "note": "weather claimed it on 2026-08-07 and answered \"для какого города?\", because it read one percent closer than the leftover seeds. WorldQueryGrammars claims it at stage 0 now." },
{ "id": "ru-query-027", "utterance": "сколько будет 17 на 23?", "lang": "ru", "intent": "query", "want_source": "world", "tags": ["world", "arithmetic", "regression"], "note": "same source, same day, same answer about a city. Arithmetic is not a place." },
{ "id": "ru-query-028", "utterance": "какой у меня любимый язык?", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["recall", "possessive", "regression"], "note": "the feed answered it with kernel headlines. \"у меня\" is the whole signal and it points inward." },
{ "id": "ru-query-029", "utterance": "кто такой Линус Торвальдс?", "lang": "ru", "intent": "query", "want_source": "world", "tags": ["world", "person", "regression"], "note": "the personal boundary answered \"не нашла у тебя такой записи\". A named public person is not his data." },
{ "id": "ru-query-030", "utterance": "что там с бэкапами?", "lang": "ru", "intent": "query", "want_source": "", "tags": ["homelab", "regression"], "note": "search claimed it, which inverts the boundary outward. The fix is the chain order and not a destination: recall, attention and network all answer it, same as ru-query-010." }
]
}
+6
View File
@@ -105,6 +105,12 @@ type Decision struct {
Slots Slots
Clarify bool // stage 3: below threshold — ask, don't guess
// Source — where the answer lives, for a query. The second half of the
// route, and empty on every other intent. SourceUnknown means no decider
// named one and the daemon walks its whole chain, which is what shipped
// before this field existed. See source.go for why it is twelve values.
Source Source
// Continued — this decision was rebuilt from the previous turn rather
// than routed, because the utterance was an ellipsis ("а завтра?").
// Handlers use it to know that Slots.Text is the PREVIOUS turn's topic
+42 -1
View File
@@ -45,12 +45,19 @@ const routeGrammar = `
root ::= "[" ws action ("," ws action)* ws "]"
action ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}"
intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\"" | "\"unknown\""
field ::= key ws ":" ws string
field ::= (key ws ":" ws string) | ("\"source\"" ws ":" ws source)
key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\""
source ::= "\"recall\"" | "\"calendar\"" | "\"tasks\"" | "\"list\"" | "\"money\"" | "\"weather\"" | "\"home\"" | "\"network\"" | "\"feeds\"" | "\"attention\"" | "\"self\"" | "\"world\"" | "\"\""
string ::= "\"" ([^"\\\x00-\x1F] | "\\" ["\\/bfnrt] | "\\u" [0-9a-fA-F]{4}){0,120} "\""
ws ::= [ \t\n]{0,4}
`
// TestRouteGrammarCoversSources holds the source rule above to router.Sources.
// The enum is the point: a grammar cannot emit a destination that does not
// exist, which is the guarantee V-546 wants from a softmax and gets here for
// free. Empty is the thirteenth alternative and it is not an oversight — it is
// the SourceUnknown floor, and the model must be able to decline.
// routeSystem — the router prompt. Changed 31-07-2026: the query test now sits
// above the fact test and there is an explicit question test. Before that, a
// question naming a fact key ("сколько воды я выпил с утра") matched the fact
@@ -121,6 +128,31 @@ const routeSystem = `Классифицируй ровно одно сообще
"что такое кватернион?" {"intent":"query","text":"что такое кватернион"}
"ага" {"intent":"chat","text":"ага"}
Только для query добавь поле source где лежит ответ:
- recall его заметки, факты и то, что он раньше говорил
- calendar встречи и события
- tasks список задач
- list списки покупок и другие именованные списки
- money траты
- weather погода
- home свет, устройства, дом
- network локальная сеть, сервер, диски
- feeds новостные ленты
- attention что требует внимания сейчас
- self вопрос про самого ассистента
- world всё остальное: определения, счёт, люди, факты о мире
Пустое значение "" нормальный ответ и его надо ставить часто. Ставь "", если ответ могут дать сразу два источника или если не уверен: тогда проверяются все по порядку, и это правильно. Никогда не угадывай.
"сколько воды я выпил с утра" {"intent":"query","text":"сколько воды я выпил с утра","source":"recall"}
"что я записывал про кота" {"intent":"query","text":"что я записывал про кота","source":"recall"}
"во сколько у меня встреча" {"intent":"query","text":"во сколько у меня встреча","source":"calendar"}
"что такое docker?" {"intent":"query","text":"что такое docker","source":"world"}
"кто такой Линус Торвальдс?" {"intent":"query","text":"кто такой Линус Торвальдс","source":"world"}
"сколько будет 17 на 23?" {"intent":"query","text":"сколько будет 17 на 23","source":"world"}
"почему сервер тормозит" {"intent":"query","text":"почему сервер тормозит","source":""}
"есть новости по бэкапу базы" {"intent":"query","text":"есть новости по бэкапу базы","source":""}
Ответ JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только JSON, без пояснений.`
// routeRepeatPenalty — the sub-1B model loops one sentence inside the text field
@@ -172,6 +204,7 @@ type routeAction struct {
Value string `json:"value"`
Text string `json:"text"`
Verb string `json:"verb"`
Source string `json:"source"`
}
// Route asks the model for one decision. The bool is false when there is no
@@ -240,6 +273,14 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time)
case IntentQuery:
d.Intent = IntentQuery
d.Slots.Text = firstNonEmpty(a.Text, utterance)
// Through ValidSource, and on query alone. The grammar already bounds
// the enum, but the grammar is a request to a server that may be
// running a different build, and a destination this binary does not
// know would take real query sources off the turn. Anything unknown
// drops to SourceUnknown, which is the floor and costs nothing.
if ValidSource(Source(a.Source)) {
d.Source = Source(a.Source)
}
case IntentAct:
d.Intent = IntentAct
d.Slots.Text = firstNonEmpty(a.Verb, utterance)
+67
View File
@@ -398,3 +398,70 @@ func TestLLMReminderWithSubjectIsNotGated(t *testing.T) {
t.Fatalf("a complete reminder was sent back as a question: %+v", d.Slots)
}
}
// TestRouteGrammarCoversSources — the grammar enum and router.Sources are two
// hand-written lists of the same twelve destinations, and nothing else notices
// when one grows. A destination missing from the grammar is a destination the
// model is structurally unable to name, which is the exact defect V-517
// measured for Praxis: not a weak model, an absent string.
func TestRouteGrammarCoversSources(t *testing.T) {
for _, s := range Sources {
if !strings.Contains(routeGrammar, `"\"`+string(s)+`\""`) {
t.Errorf("routeGrammar cannot emit %q — the model can never name it", s)
}
}
// The floor has to be reachable too, or the model is forced to pick one.
if !strings.Contains(routeGrammar, `"\"\""`) {
t.Error(`routeGrammar cannot emit "" — the model cannot decline a destination`)
}
// Count the alternatives on the source rule: an extra one is a destination
// the daemon would drop to SourceUnknown after the model spent tokens on it.
for _, line := range strings.Split(routeGrammar, "\n") {
if !strings.HasPrefix(line, "source ") {
continue
}
if got, want := strings.Count(line, "|")+1, len(Sources)+1; got != want {
t.Errorf("source rule has %d alternatives, want %d (Sources plus the floor)", got, want)
}
}
}
// The destination is read back only through ValidSource. A model on an older or
// newer build can write a string this binary does not know, and trusting it
// would take real query sources off the turn for a name nothing answers.
func TestLLMUnknownSourceFallsToTheFloor(t *testing.T) {
r := newLLMTestRouter(t, `{"intent":"query","text":"что там с бэкапами","source":"praxis"}`)
d, err := r.Route(context.Background(), "что там с бэкапами", refNow())
if err != nil {
t.Fatalf("route: %v", err)
}
if d.Source != SourceUnknown {
t.Fatalf("invented destination %q was trusted, want the floor", d.Source)
}
}
// And a known one survives, or the read-back is just a filter.
func TestLLMNamedSourceSurvives(t *testing.T) {
r := newLLMTestRouter(t, `{"intent":"query","text":"кто такой Линус Торвальдс","source":"world"}`)
d, err := r.Route(context.Background(), "кто такой Линус Торвальдс?", refNow())
if err != nil {
t.Fatalf("route: %v", err)
}
if d.Source != SourceWorld {
t.Fatalf("source %q, want %q", d.Source, SourceWorld)
}
}
// A destination on anything but a query is dropped. Only IntentQuery reaches
// queryWalk, so a source elsewhere is a field nobody reads and a claim nobody
// checks.
func TestLLMSourceIsQueryOnly(t *testing.T) {
r := newLLMTestRouter(t, `{"intent":"note","text":"кофе кончился","source":"recall"}`)
d, err := r.Route(context.Background(), "запиши что кофе кончился", refNow())
if err != nil {
t.Fatalf("route: %v", err)
}
if d.Source != SourceUnknown {
t.Fatalf("a note carried destination %q", d.Source)
}
}
+78
View File
@@ -0,0 +1,78 @@
package router
// Source — where the answer to a query lives. It is the second half of a
// routing decision and it used to be made outside the router entirely (V-655).
//
// The cascade sorted an utterance into one of seven intents with stage 0 rules,
// the resident model and the classifier behind it, a fixture measuring it and
// the decision trace recording it. Then IntentQuery handed the turn to
// querySources in the daemon, a chain of twenty-two branches deciding by seed
// similarity in a fixed order, with none of that. So the careful sorter did the
// easy half and the sloppy one did the hard half: on 2026-08-07 weather claimed
// "что такое TCP?" and answered "для какого города?", because weather read one
// percent closer to the turn than the pile of leftover seeds did, and one
// percent was enough. Search would have answered it and search was never asked.
//
// "query" is not a destination. It is a shrug. This is the field that says
// where to look.
//
// # Why twelve and not twenty-two
//
// A destination is what a decider can plausibly name from the utterance alone,
// not one entry per source. Three of the daemon's sources are successive passes
// over his own words and a fourth reads the facts by key: which of them lands
// the hit is an ordering detail inside the chain, and no utterance says. They
// are SourceRecall together. The same goes for the metasearch, the offline
// encyclopedia and a page he named by URL, which are SourceWorld.
//
// # Empty is a real value and it is the floor
//
// SourceUnknown means nobody decided. The daemon then walks the whole chain in
// its original order, which is the behaviour that shipped before this field
// existed. So the classifier arm sets nothing and costs nothing, and a box
// whose model is down routes queries exactly as it did.
type Source string
const (
// SourceUnknown — no decider named a destination. Walk the chain.
SourceUnknown Source = ""
// His own data.
SourceRecall Source = "recall" // notes, facts and what he has said before
SourceCalendar Source = "calendar" // events, and the only date-aware destination
SourceTasks Source = "tasks" // the task list
SourceList Source = "list" // the shopping and other named lists
SourceMoney Source = "money" // the spending facts the poller writes
// The surroundings.
SourceWeather Source = "weather" // the forecast for a place
SourceHome Source = "home" // lights, devices, the house
SourceNetwork Source = "network" // the LAN and what is on it
SourceFeeds Source = "feeds" // the RSS she reads
SourceAttention Source = "attention" // what Praxis says needs looking at
// Everything else.
SourceSelf Source = "self" // a question about Maven herself
SourceWorld Source = "world" // search, the ZIMs, a page he named
)
// Sources — every destination a decider may name, in a fixed order so a prompt,
// a grammar table and a test all read the same list. SourceUnknown is not a
// member: it is the absence of a choice, not one of the choices.
var Sources = []Source{
SourceRecall, SourceCalendar, SourceTasks, SourceList, SourceMoney,
SourceWeather, SourceHome, SourceNetwork, SourceFeeds, SourceAttention,
SourceSelf, SourceWorld,
}
// ValidSource reports whether s is one a decider may name. Anything else,
// including a destination invented by a model, is dropped back to
// SourceUnknown by the caller rather than trusted.
func ValidSource(s Source) bool {
for _, known := range Sources {
if s == known {
return true
}
}
return false
}
+20 -5
View File
@@ -187,9 +187,11 @@ func SystemTimeDateGrammars() []Grammar {
// written ("the clock/date system rule must not swallow it"); the daemon
// disagreed with the fixture and the daemon was wrong.
//
// Routing, not answering. These set the intent and nothing else — which source
// in the query chain claims the turn stays the chain's decision, and a
// question with no date still falls through queryCalendar to recall.
// Routing, not answering. Two of the five also name the calendar as the
// destination (V-655), which narrows who may GUESS their way onto the turn and
// claims nothing. Every source that looks something up still runs, in the order
// it always did, so a question with no date still falls through queryCalendar
// to recall.
//
// Deliberately not folded into SystemTimeDateGrammars: those exist to send
// utterances TO system, these exist to keep utterances OUT of it, and one
@@ -199,9 +201,15 @@ func AgendaQueryGrammars() []Grammar {
{
// An explicit calendar noun is unambiguous wherever it appears:
// "что в календаре на завтра", "покажи расписание на среду".
//
// The one agenda rule that names its destination, because an
// explicit calendar noun leaves nothing to weigh (V-655). The
// possessive rules below deliberately do not: "что у меня в списке
// покупок" matches agenda-query, and naming the calendar there
// would take the list source off the turn.
Name: "calendar-query",
Pattern: regexp.MustCompile(`(?i)(календар|расписани|повестк)`),
Build: agendaQueryBuild,
Build: queryTo(SourceCalendar),
},
{
// The agenda phrasing with no calendar noun. Anchored at the start
@@ -251,9 +259,11 @@ func AgendaQueryGrammars() []Grammar {
// "во сколько созвон". He is asking when something on his calendar
// happens, and the noun is the only signal. Closed list, so "когда
// битва при Ватерлоо" is still a world question.
// Names the calendar (V-655): the noun list is closed and every
// member of it is an event, so there is nothing else to weigh.
Name: "event-time-query",
Pattern: regexp.MustCompile(`(?i)^\s*(когда|во\s+сколько|в\s+котором\s+часу)\s+(будет\s+|у\s+нас\s+)?(планёрк|планерк|встреч|созвон|митинг|совещани|звонок|созвон|приём|прием|интервью|собеседовани|тренировк|урок|занятие|пара)[а-я]*(\s|[?!.]|$)`),
Build: agendaQueryBuild,
Build: queryTo(SourceCalendar),
},
}
}
@@ -340,6 +350,11 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
Intent: IntentQuery,
Confidence: 1.0,
Slots: Slots{Text: topic},
// The world, because that is the shape this asks for and the rule has
// already declined the two cases where it is not: entertainment, and
// questions about her (V-655). His own notes are still read first — a
// destination narrows who may guess and reorders nothing.
Source: SourceWorld,
}, true
}
+86
View File
@@ -0,0 +1,86 @@
package router
import "regexp"
// WorldQueryGrammars — stage-0 rules for the two question shapes that name the
// world in their own words, and say so plainly enough that no scorer is needed
// (V-655).
//
// They exist because of what happens when nothing deterministic claims these.
// Measured on the box on 2026-08-07 (docs/evals/2026-08-07-week-of-usage.md,
// section 4): "что такое TCP?" and "сколько будет 17 на 23?" were both answered
// "для какого города?", and "кто такой Линус Торвальдс?" was answered "не знаю —
// не нашла у тебя такой записи". None of those three is about him, about the
// weather, or about anything on this box.
//
// The mechanism is the destination, not the answer. Naming SourceWorld does not
// send the turn outside and does not skip a single source that looks something
// up: his notes, his facts and the personal boundary all still run first, in the
// order they always did. What it does is stop the sources that claim on seed
// similarity from taking the turn on the way past. Weather cannot claim a
// question about a protocol once the utterance has said which side it is on.
//
// Both patterns are spelled out here rather than drawn from internal/lexicon,
// which is the same call the agenda rules made: these are interrogative FRAMES
// of two words, not a closed class of single words, and the lexicon holds
// classes. Nothing here is a stem pattern over open vocabulary — the variable
// part of each rule is the topic, and the rule reads none of it.
func WorldQueryGrammars() []Grammar {
return []Grammar{
{
// "что такое X", "кто такой X". A request for what a thing or a
// person IS, which his own data can answer and usually cannot.
//
// The topic is deliberately not captured into Slots.Text. Every
// source below reads the utterance, "что такое TCP?" is already the
// best query string for it, and the agenda rules make the same call
// for the same reason.
Name: "definition-query",
Pattern: definitionQueryPattern,
Build: queryTo(SourceWorld),
},
{
// "сколько будет 17 на 23", "сколько будет 2+2". Arithmetic, which
// the metasearch answers and no local source holds. The digits are
// what make it arithmetic: "сколько будет гостей" names no number
// and is a question about his evening.
Name: "arithmetic-query",
Pattern: arithmeticQueryPattern,
Build: queryTo(SourceWorld),
},
}
}
// definitionQueryPattern — anchored at the start, because "напомни узнать что
// такое TCP" is a reminder that happens to contain the frame.
//
// (\s|[?!.]|$) and not \b: Go's \b is ASCII-only and never fires after a
// Cyrillic letter, so the ASCII form silently matches nothing. The agenda rules
// carry the same note.
var definitionQueryPattern = regexp.MustCompile(
`(?i)^\s*(что\s+так(ое|ая)|кто\s+так(ой|ая|ие)|what\s+is|who\s+is)(\s|[?!.]|$)`)
// arithmeticQueryPattern — the ask, then a digit somewhere after it. Loose on
// what sits between them on purpose: the operator is spoken half a dozen ways
// ("на", "умножить на", "плюс", "+") and reading them is the calculator's job,
// not this rule's. All this decides is which side of the boundary the turn is
// on.
var arithmeticQueryPattern = regexp.MustCompile(
`(?i)^\s*(сколько\s+будет|посчитай|вычисли|how\s+much\s+is)\s.*\d`)
// queryTo builds a stage-0 query Decision that names where the answer lives.
//
// The utterance travels intact and no slot is filled, which is the same
// contract agendaQueryBuild has: confidence 1.0 on the intent and the
// destination, and every source below still decides for itself whether it has
// an answer. Naming a destination narrows who may guess. It promises nothing.
func queryTo(dest Source) func([]string) (Decision, bool) {
return func([]string) (Decision, bool) {
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1.0,
Source: dest,
}, true
}
}
+88
View File
@@ -0,0 +1,88 @@
package router
import "testing"
// The three utterances from the 2026-08-07 week on the box that no local source
// could answer and three different local sources claimed anyway. Stage 0 has to
// say which side of the boundary they are on, because by the time the chain is
// walking, the only thing separating them from a weather forecast is a cosine.
func TestAWorldQuestionNamesTheWorld(t *testing.T) {
cases := []struct {
utterance string
rule string
}{
{"что такое TCP?", "definition-query"},
{"кто такой Линус Торвальдс?", "definition-query"},
{"что такая мембрана", "definition-query"},
{"кто такая Ада Лавлейс?", "definition-query"},
{"what is TCP?", "definition-query"},
{"сколько будет 17 на 23?", "arithmetic-query"},
{"посчитай 2+2", "arithmetic-query"},
{"сколько будет 5 умножить на 6", "arithmetic-query"},
}
for _, c := range cases {
dec, rule, ok := matchWorldQuery(c.utterance)
if !ok {
t.Errorf("%q: no world rule claimed it", c.utterance)
continue
}
if rule != c.rule {
t.Errorf("%q: claimed by %q, want %q", c.utterance, rule, c.rule)
}
if dec.Intent != IntentQuery {
t.Errorf("%q: intent %q, want query", c.utterance, dec.Intent)
}
if dec.Source != SourceWorld {
t.Errorf("%q: source %q, want %q", c.utterance, dec.Source, SourceWorld)
}
}
}
// The frame has to be the whole opening or the rule is reading somebody else's
// sentence. Every case here contains a world-question shape and is not one.
func TestAWorldRuleDeclinesWhatIsNotItsShape(t *testing.T) {
cases := []struct {
utterance string
why string
}{
{"напомни узнать что такое TCP", "a reminder that happens to quote the frame"},
{"запиши что такое TCP", "a capture that happens to quote the frame"},
{"сколько будет гостей", "an ask with no number is not arithmetic"},
{"что у меня сегодня?", "his agenda, and the agenda rules own it"},
{"кто там?", "not the frame"},
{"посчитай расходы", "no number, so the money source keeps it"},
}
for _, c := range cases {
if _, rule, ok := matchWorldQuery(c.utterance); ok {
t.Errorf("%q: claimed by %q, want no claim — %s", c.utterance, rule, c.why)
}
}
}
// The destination is advice about who may guess, never a filled slot. A rule
// that quietly captured the topic would change what every source below reads.
func TestNamingTheWorldFillsNoSlot(t *testing.T) {
dec, _, ok := matchWorldQuery("что такое TCP?")
if !ok {
t.Fatal("definition-query did not claim it")
}
if dec.Slots.Text != "" || dec.Slots.HasTime || dec.Slots.HasFn || dec.Slots.HasKey {
t.Errorf("slots = %+v, want none filled", dec.Slots)
}
if dec.Confidence != 1.0 {
t.Errorf("confidence = %v, want 1.0 for a stage-0 match", dec.Confidence)
}
}
func matchWorldQuery(utterance string) (Decision, string, bool) {
for _, g := range WorldQueryGrammars() {
m := g.Pattern.FindStringSubmatch(utterance)
if m == nil {
continue
}
if dec, ok := g.Build(m); ok {
return dec, g.Name, true
}
}
return Decision{}, "", false
}
+37 -6
View File
@@ -89,8 +89,8 @@ type Poller struct {
ranker Ranker
cfg Config
nextDue map[string]time.Time
seen map[string]map[string]bool // feed → item ID, for items with no date
polled map[string]bool // feed → polled at least once in THIS process
seen map[string]*seenIDs // feed → item IDs, for items with no date
polled map[string]bool // feed → polled at least once in THIS process
}
// NewPoller wires a poller. Returns nil when there is nothing to poll — a
@@ -122,7 +122,7 @@ func NewPoller(feeds []FeedConfig, fetch Fetcher, notes Notes, marks Marks, embe
feeds: valid, fetch: fetch, notes: notes, marks: marks,
embed: embed, ranker: ranker, cfg: cfg,
nextDue: map[string]time.Time{},
seen: map[string]map[string]bool{},
seen: map[string]*seenIDs{},
polled: map[string]bool{},
}
}
@@ -283,6 +283,38 @@ func (p *Poller) mark(ctx context.Context, feed string, now time.Time) (time.Tim
return at, true
}
// maxSeenPerFeed bounds the undated-item set. It has to stay comfortably above
// any one feed's front page, or an item still listed there would fall out of the
// set and be written a second time. A few hundred entries covers the largest
// page anyone publishes, and the set only has to span one poll window plus the
// resync guard, not all of history.
const maxSeenPerFeed = 512
// seenIDs is a bounded insertion-ordered set. The map answers the lookup, the
// slice remembers what to drop first, so an undated feed cannot grow the poller
// for as long as mavend runs.
type seenIDs struct {
ids map[string]bool
order []string
}
// add records id and reports whether it was new.
func (s *seenIDs) add(id string) bool {
if s.ids == nil {
s.ids = make(map[string]bool, maxSeenPerFeed)
}
if s.ids[id] {
return false
}
s.ids[id] = true
s.order = append(s.order, id)
if len(s.order) > maxSeenPerFeed {
delete(s.ids, s.order[0])
s.order = s.order[1:]
}
return true
}
// fresh — two dedup rules, because feeds are inconsistent about dates. A dated
// item must be newer than the mark; an undated one is kept once per process by
// ID.
@@ -308,12 +340,11 @@ func (p *Poller) fresh(f FeedConfig, it Item, mark, now time.Time, resync bool)
id = it.Title
}
if p.seen[f.Name] == nil {
p.seen[f.Name] = map[string]bool{}
p.seen[f.Name] = &seenIDs{}
}
if p.seen[f.Name][id] {
if !p.seen[f.Name].add(id) {
return false
}
p.seen[f.Name][id] = true
return !resync
}
+25
View File
@@ -3,6 +3,7 @@ package rss
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
@@ -208,3 +209,27 @@ func TestNoFeedsMeansNoPoller(t *testing.T) {
t.Fatal("a feed with no name or url is not a configuration")
}
}
// An undated feed used to grow p.seen for as long as mavend ran. The set is
// bounded now, and the bound must not cost the dedupe an item still on the
// front page — only ids far older than any page fall out.
func TestSeenIDsBounded(t *testing.T) {
var s seenIDs
for i := 0; i < maxSeenPerFeed*3; i++ {
if !s.add(fmt.Sprintf("item-%d", i)) {
t.Fatalf("item-%d read as already seen", i)
}
if len(s.ids) > maxSeenPerFeed || len(s.order) > maxSeenPerFeed {
t.Fatalf("after %d inserts: ids=%d order=%d, cap is %d",
i+1, len(s.ids), len(s.order), maxSeenPerFeed)
}
}
// The newest insert is still deduped; the oldest was evicted.
last := fmt.Sprintf("item-%d", maxSeenPerFeed*3-1)
if s.add(last) {
t.Fatalf("%s read as new, so the most recent id was dropped", last)
}
if !s.add("item-0") {
t.Fatal("item-0 survived, so nothing was evicted")
}
}
+135 -11
View File
@@ -68,6 +68,13 @@ func (m *MemoryStore) Insert(ctx context.Context, id string, vec []float32, meta
// Rows under memory.NonRecallPrefix are excluded in SQL. They are speaker
// voiceprints sharing this table, and note recall must not rank them; see that
// constant for why the previous arrangement only appeared to do this.
//
// Every row is still scored, because a full scan is what picks the winners.
// What the scan does NOT do is pay for a row it is about to discard: the score
// is read straight off the stored bytes without materializing a []float32, and
// the meta blob is copied and unmarshalled only for a row that has entered the
// topK. Losers cost one dot product and nothing else. Ranking is unchanged —
// same scores, same order, same ties.
func (m *MemoryStore) Search(ctx context.Context, vec []float32, topK int) ([]memory.Result, error) {
if topK <= 0 {
topK = 10
@@ -80,30 +87,127 @@ func (m *MemoryStore) Search(ctx context.Context, vec []float32, topK int) ([]me
}
defer rows.Close()
var out []memory.Result
// sql.RawBytes hands us the driver's own buffer, valid only until the next
// Next(). Nothing here outlives the row except what topK.offer copies on a
// survivor, so the three columns cost no allocation per row.
var id, blob, metaJSON sql.RawBytes
top := newTopK(topK)
for rows.Next() {
var id, metaJSON string
var blob []byte
if err := rows.Scan(&id, &blob, &metaJSON); err != nil {
return nil, fmt.Errorf("memory: row: %w", err)
}
meta := map[string]string{}
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
return nil, fmt.Errorf("memory: unmarshal meta for %q: %w", id, err)
}
out = append(out, memory.Result{ID: id, Score: dot(vec, decodeVec(blob)), Meta: meta})
top.offer(dotBlob(vec, blob), id, metaJSON)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: rows: %w", err)
}
sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
if topK < len(out) {
out = out[:topK]
survivors := top.sorted()
out := make([]memory.Result, 0, len(survivors))
for _, c := range survivors {
meta := map[string]string{}
if err := json.Unmarshal(c.meta, &meta); err != nil {
return nil, fmt.Errorf("memory: unmarshal meta for %q: %w", c.id, err)
}
out = append(out, memory.Result{ID: c.id, Score: c.score, Meta: meta})
}
return out, nil
}
// candidate is one row that is currently in the topK: its score, its id, and
// its meta blob copied out of the driver's buffer. The copy is the price of
// surviving, and only survivors pay it.
type candidate struct {
score float64
id string
meta []byte
}
// topK keeps the k highest-scoring candidates seen so far as a min-heap, so the
// weakest survivor is always heap[0] and one comparison decides whether a new
// row is worth copying. k is 10 in practice, so the heap is tiny and the whole
// structure fits in cache.
//
// It is a plain slice with hand-written sift operations rather than
// container/heap, because that interface boxes every element into an `any` on
// Push and costs an allocation per surviving row.
type topK struct {
k int
heap []candidate
}
func newTopK(k int) *topK {
return &topK{k: k, heap: make([]candidate, 0, k)}
}
// offer admits a row if it beats the weakest survivor, or if the heap is not
// full yet. id and meta are the driver's buffers and are copied here, never
// retained.
//
// A row that only ties the weakest survivor does not displace it, so among
// equal scores the earliest k rows are kept. The full sort this replaced used
// sort.Slice, which is not stable, so it broke such a tie arbitrarily. That is
// the ONE observable difference between the two, and it is deliberate:
// deterministic beats arbitrary.
//
// It is not academic. Under the real embedder an exact tie means duplicate
// vectors and nothing in the recall eval moved (V-643). Under the hash
// embedder the eval's deterministic floor uses, ties are everywhere — it is
// bag-of-words, so every note sharing no word with the query scores exactly 0
// — and recall@3 on that run moved 74.1% to 81.5% purely because the zeros now
// come out in a fixed order. Neither number measures retrieval. recall@1 and
// false recall, which the eval actually asserts, are unchanged on both runs.
func (t *topK) offer(score float64, id, meta []byte) {
if t.k == 0 {
return
}
if len(t.heap) < t.k {
t.heap = append(t.heap, candidate{score: score, id: string(id), meta: append([]byte(nil), meta...)})
t.up(len(t.heap) - 1)
return
}
if score <= t.heap[0].score {
return
}
t.heap[0] = candidate{score: score, id: string(id), meta: append([]byte(nil), meta...)}
t.down(0)
}
func (t *topK) up(i int) {
for i > 0 {
parent := (i - 1) / 2
if t.heap[parent].score <= t.heap[i].score {
return
}
t.heap[parent], t.heap[i] = t.heap[i], t.heap[parent]
i = parent
}
}
func (t *topK) down(i int) {
for {
l, r, small := 2*i+1, 2*i+2, i
if l < len(t.heap) && t.heap[l].score < t.heap[small].score {
small = l
}
if r < len(t.heap) && t.heap[r].score < t.heap[small].score {
small = r
}
if small == i {
return
}
t.heap[small], t.heap[i] = t.heap[i], t.heap[small]
i = small
}
}
// sorted drains the heap into descending score order — what Search returns.
func (t *topK) sorted() []candidate {
out := t.heap
sort.Slice(out, func(i, j int) bool { return out[i].score > out[j].score })
return out
}
// ByPrefix returns every row whose id starts with prefix, vectors included.
//
// This is not a similarity query and deliberately does not score anything:
@@ -240,6 +344,26 @@ func decodeVec(b []byte) []float32 {
return v
}
// dotBlob is dot against a vector still in its stored encoding, so scoring a
// row the query is about to discard does not allocate the []float32 that
// decodeVec would build. Same arithmetic, same order of operations, so it
// returns bit-identical scores to dot(a, decodeVec(b)).
//
// A blob whose length isn't a multiple of 4 is truncated to the whole-element
// prefix, matching decodeVec, and a length mismatch is 0, matching dot.
func dotBlob(a []float32, b []byte) float64 {
n := len(b) / 4
if len(a) != n || n == 0 {
return 0
}
var sum float64
for i := 0; i < n; i++ {
f := math.Float32frombits(binary.LittleEndian.Uint32(b[4*i:]))
sum += float64(a[i]) * float64(f)
}
return sum
}
// dot is the cosine similarity for L2-normalized vectors (mismatched lengths ⇒
// 0, matching internal/memory's cosine).
func dot(a, b []float32) float64 {
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"context"
"fmt"
"math"
"math/rand"
"path/filepath"
"testing"
)
// benchDim is the resident embedder's width (multilingual-e5-small, 384), so
// the per-row decode cost the benchmark measures is the real one.
const benchDim = 384
// seedMemVectors fills a fresh store with n L2-normalized rows carrying a meta
// blob the size recall actually stores — the note text plus its type — because
// the cost this benchmark exists to measure is unmarshalling that blob for
// every row when only topK survivors need it.
func seedMemVectors(tb testing.TB, n int) *MemoryStore {
tb.Helper()
path := filepath.Join(tb.TempDir(), "mem_bench.db")
st, err := Open(context.Background(), path)
if err != nil {
tb.Fatalf("Open: %v", err)
}
tb.Cleanup(func() { _ = st.Close() })
m := st.VectorMemory()
rng := rand.New(rand.NewSource(1))
ctx := context.Background()
for i := 0; i < n; i++ {
if err := m.Insert(ctx, fmt.Sprintf("note:%d", i), randUnitVec(rng, benchDim), map[string]string{
"type": "note",
"text": fmt.Sprintf("заметка номер %d о том, что надо не забыть сделать на неделе", i),
}); err != nil {
tb.Fatalf("Insert %d: %v", i, err)
}
}
return m
}
func randUnitVec(rng *rand.Rand, dim int) []float32 {
v := make([]float32, dim)
var norm float64
for i := range v {
f := rng.NormFloat64()
v[i] = float32(f)
norm += f * f
}
norm = math.Sqrt(norm)
for i := range v {
v[i] = float32(float64(v[i]) / norm)
}
return v
}
// BenchmarkMemoryStoreSearch measures one recall query against a store of n
// rows. Row counts bracket the documented scale: 1000 is a plausible today,
// 10000 is the "thousands, not millions" ceiling the type doc claims a full
// scan is fine at.
func BenchmarkMemoryStoreSearch(b *testing.B) {
for _, n := range []int{1000, 10000} {
b.Run(fmt.Sprintf("rows=%d", n), func(b *testing.B) {
m := seedMemVectors(b, n)
q := randUnitVec(rand.New(rand.NewSource(2)), benchDim)
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := m.Search(ctx, q, 10); err != nil {
b.Fatal(err)
}
}
})
}
}
+107
View File
@@ -0,0 +1,107 @@
package store
import (
"context"
"fmt"
"math/rand"
"sort"
"testing"
"github.com/kami/maven/internal/memory"
)
// naiveSearch is the implementation Search replaced: score every row into a
// slice, sort the whole slice, truncate. It stays in the test file as the
// reference the bounded-heap version is judged against, because "recall must
// not change" is a claim about output, not about the code that produces it.
func naiveSearch(t *testing.T, m *MemoryStore, vec []float32, topK int) []memory.Result {
t.Helper()
rows, err := m.db.QueryContext(context.Background(),
`SELECT id, vec FROM memory_vectors WHERE id NOT LIKE ? ESCAPE '\'`,
escapeLike(memory.NonRecallPrefix)+"%")
if err != nil {
t.Fatalf("naive scan: %v", err)
}
defer rows.Close()
var out []memory.Result
for rows.Next() {
var id string
var blob []byte
if err := rows.Scan(&id, &blob); err != nil {
t.Fatalf("naive row: %v", err)
}
out = append(out, memory.Result{ID: id, Score: dot(vec, decodeVec(blob))})
}
if err := rows.Err(); err != nil {
t.Fatalf("naive rows: %v", err)
}
sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
if topK < len(out) {
out = out[:topK]
}
return out
}
// TestMemoryStoreSearchMatchesNaive is the constraint on V-643: the bounded
// heap must return exactly what a full scan and sort returned. Distinct random
// vectors, so no two scores tie and the ranking is total — a mismatch here is
// arithmetic or heap logic, not a tie-break difference.
func TestMemoryStoreSearchMatchesNaive(t *testing.T) {
ctx := context.Background()
m := newMemTestStore(t).VectorMemory()
rng := rand.New(rand.NewSource(7))
const rows, dim = 500, 64
for i := 0; i < rows; i++ {
if err := m.Insert(ctx, fmt.Sprintf("n%d", i), randUnitVec(rng, dim), map[string]string{
"text": fmt.Sprintf("note %d", i),
}); err != nil {
t.Fatalf("Insert %d: %v", i, err)
}
}
for _, topK := range []int{1, 3, 10, 50, rows, rows + 100} {
q := randUnitVec(rng, dim)
got, err := m.Search(ctx, q, topK)
if err != nil {
t.Fatalf("Search topK=%d: %v", topK, err)
}
want := naiveSearch(t, m, q, topK)
if len(got) != len(want) {
t.Fatalf("topK=%d: got %d results, naive returned %d", topK, len(got), len(want))
}
for i := range want {
if got[i].ID != want[i].ID {
t.Errorf("topK=%d rank %d: got %q, naive says %q", topK, i, got[i].ID, want[i].ID)
}
if got[i].Score != want[i].Score {
t.Errorf("topK=%d rank %d (%s): score %v, naive says %v",
topK, i, got[i].ID, got[i].Score, want[i].Score)
}
}
if len(got) > 0 && got[0].Meta["text"] == "" {
t.Errorf("topK=%d: survivor %s has no meta — it was never unmarshalled", topK, got[0].ID)
}
}
}
// TestDotBlobMatchesDot pins the claim in dotBlob's doc comment: reading the
// vector out of its stored bytes is bit-identical to decoding it first. Scores
// feed a gate with a 0.008 margin, so "close enough" is not the bar.
func TestDotBlobMatchesDot(t *testing.T) {
rng := rand.New(rand.NewSource(11))
for i := 0; i < 200; i++ {
a := randUnitVec(rng, 384)
b := randUnitVec(rng, 384)
if got, want := dotBlob(a, encodeVec(b)), dot(a, b); got != want {
t.Fatalf("dotBlob = %v, dot = %v", got, want)
}
}
// Length mismatch is 0 in both, and so is an empty vector.
if got := dotBlob([]float32{1, 0}, encodeVec([]float32{1, 0, 0})); got != 0 {
t.Errorf("mismatched lengths scored %v, want 0", got)
}
if got := dotBlob(nil, nil); got != 0 {
t.Errorf("empty scored %v, want 0", got)
}
}
+22
View File
@@ -295,6 +295,27 @@ func (f *Fetcher) checkURL(u *url.URL) error {
return nil
}
// pruneHostsAbove is when pruneLocked bothers to walk the map. Below it the
// walk costs more than the entries do, and `crawl.on_demand` means the host set
// is whatever he names out loud, so it grows slowly.
const pruneHostsAbove = 64
// pruneLocked drops hosts whose last dial is further back than HostInterval.
// Such an entry cannot delay anything — waitTurn would let the next request
// through immediately — so keeping it only holds memory for the life of the
// process. Caller holds f.mu.
func (f *Fetcher) pruneLocked(now time.Time) {
if len(f.last) <= pruneHostsAbove {
return
}
cutoff := now.Add(-f.cfg.HostInterval)
for h, at := range f.last {
if at.Before(cutoff) {
delete(f.last, h)
}
}
}
// waitTurn blocks until this host's rate-limit interval has elapsed. It holds
// no lock while sleeping, so two hosts never wait on each other.
func (f *Fetcher) waitTurn(ctx context.Context, host string) error {
@@ -304,6 +325,7 @@ func (f *Fetcher) waitTurn(ctx context.Context, host string) error {
earliest := f.last[host].Add(f.cfg.HostInterval)
if !now.Before(earliest) {
f.last[host] = now
f.pruneLocked(now)
f.mu.Unlock()
return nil
}
+35
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
@@ -318,3 +319,37 @@ func TestPostObeysDenylist(t *testing.T) {
t.Fatalf("error = %v, want ErrBlocked", err)
}
}
// f.last used to hold one entry per host ever dialed, for the life of the
// process. A host whose last dial is older than HostInterval cannot delay
// anything, so it is dropped once the map is worth walking.
func TestHostRateMapIsPruned(t *testing.T) {
f := New(Config{HostInterval: time.Minute, AllowPrivate: true})
stale := time.Now().Add(-time.Hour)
for i := 0; i < pruneHostsAbove*2; i++ {
f.last[fmt.Sprintf("h%d.example", i)] = stale
}
// One real turn is what triggers the sweep.
if err := f.waitTurn(context.Background(), "fresh.example"); err != nil {
t.Fatal(err)
}
if len(f.last) != 1 {
t.Fatalf("len(f.last) = %d after the sweep, want 1 (only the host just dialed)", len(f.last))
}
if _, ok := f.last["fresh.example"]; !ok {
t.Fatal("the host just dialed was pruned, so its own rate limit is lost")
}
// A host inside the interval is kept: pruning must not hand out a free turn.
f.last["recent.example"] = time.Now()
for i := 0; i < pruneHostsAbove*2; i++ {
f.last[fmt.Sprintf("g%d.example", i)] = stale
}
if err := f.waitTurn(context.Background(), "other.example"); err != nil {
t.Fatal(err)
}
if _, ok := f.last["recent.example"]; !ok {
t.Fatal("a host dialed inside HostInterval was pruned")
}
}
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# audit.sh — the repo inventory, in one command.
#
# Every section here was reconstructed by hand, from scratch, in session after
# session: 93 grep sweeps across the four longest ones before a single edit was
# made. The answers move slowly and the sweeps did not, so they are written down
# once here instead.
#
# It prints and never writes. A committed inventory file goes stale silently and
# then lies; a report you regenerate cannot.
#
# Read-only. Safe to run at any point, including mid-conflict.
#
# make audit # everything
# make audit SECTION=todo # one section: loc, todo, stubs, docs, tests, gaps
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
SECTION="${SECTION:-all}"
want() { [ "$SECTION" = all ] || [ "$SECTION" = "$1" ]; }
rule() { printf '\n=== %s %s\n' "$1" "$(printf '%.0s=' $(seq 1 $((66 - ${#1}))))"; }
# git grep over tracked files only. deps/ and models/ are gitignored and huge,
# and a plain grep -r walks into both.
g() { git grep -nI "$@" 2>/dev/null; }
printf 'Maven repo inventory @ %s (%s)\n' \
"$(git rev-parse --short HEAD 2>/dev/null || echo '?')" \
"$(git log -1 --format=%cs 2>/dev/null || echo '?')"
# --- loc -------------------------------------------------------------------
# Non-test Go lines per package. Size is the cheapest proxy for "where does the
# complexity actually sit", and it is the first thing every audit asked for.
if want loc; then
rule "PACKAGES BY LOC (non-test)"
for d in $(find ./cmd ./internal ./pkg -maxdepth 2 -type d 2>/dev/null | sort); do
files=$(find "$d" -maxdepth 1 -name '*.go' ! -name '*_test.go' 2>/dev/null)
[ -z "$files" ] && continue
n=$(printf '%s\n' "$files" | wc -l)
l=$(printf '%s\0' $files | xargs -0 cat 2>/dev/null | wc -l)
printf '%7d %3d files %s\n' "$l" "$n" "$d"
done | sort -rn
fi
# --- todo ------------------------------------------------------------------
if want todo; then
rule "TODO / FIXME / XXX / HACK / BUG (non-test)"
g -E '(^|[^a-zA-Z])(TODO|FIXME|XXX|HACK|BUG:)' -- 'cmd/**/*.go' 'internal/**/*.go' 'pkg/**/*.go' \
| grep -v '_test\.go:' | sed 's/^/ /' || echo " none"
fi
# --- stubs -----------------------------------------------------------------
# Code only, never doc comments. "not wired" is this repo's design vocabulary
# for a nil dependency and appears in ~30 comments that describe working code,
# so searching prose here reports the architecture back as a gap. Likewise
# internal/ipc/unimplemented.go is skipped whole: the file IS the deliberate
# Unimplemented*Server pattern, not 60 missing methods. "placeholder" is not a
# term here either -- it names real identifiers (SQL placeholders,
# Deck.RequirePlaceholder, tokenPlaceholder) and matched 16 working lines.
if want stubs; then
rule "STUBS / NOT IMPLEMENTED (code, non-test)"
g -iE 'not (yet )?implemented|unimplemented|пока не умею|panic\("TODO' \
-- 'cmd/**/*.go' 'internal/**/*.go' 'pkg/**/*.go' \
| grep -v '_test\.go:' \
| grep -v '^internal/ipc/unimplemented\.go:' \
| grep -vE '^[^:]+:[0-9]+:[[:space:]]*//' \
| sed 's/^/ /' || echo " none"
# CoreAPI stub parity. A stub missing from unimplemented.go breaks the build
# via `var _ CoreAPI = UnimplementedCoreAPI{}`. A stub left behind after its
# method leaves an interface compiles forever and is caught by nothing, so it
# is counted here until V-652 turns it into a test.
printf '\n CoreAPI stub parity:\n'
python3 - <<'PY' 2>/dev/null | sed 's/^/ /' || echo " (skipped: python3 unavailable)"
import re
src = open('internal/ipc/coreapi.go').read()
decl = set()
for m in re.finditer(r'type (\w+API) interface \{(.*?)\n\}', src, re.S):
decl |= set(re.findall(r'^\t([A-Z]\w*)\(', m.group(2), re.M))
stub = set(re.findall(r'func \(UnimplementedCoreAPI\) (\w+)\(',
open('internal/ipc/unimplemented.go').read()))
print(f"{len(decl)} declared, {len(stub)} stubbed")
for name in sorted(stub - decl):
print(f"STALE {name} (stubbed, on no interface)")
for name in sorted(decl - stub):
print(f"MISSING {name} (declared, no stub)")
PY
fi
# --- docs ------------------------------------------------------------------
# Living tier only. docs/evals/ are dated measurements that are never edited
# after the day, and docs/archive/ is dead by definition, so neither can be
# stale. Age is against the recorded date, not against HEAD: every commit moves
# HEAD, so a sha comparison would mark the whole tier stale every day.
if want docs; then
rule "LIVING DOCS — Last verified"
today=$(date +%s)
for f in docs/*.md; do
[ -e "$f" ] || continue
line=$(grep -m1 -o 'Last verified: *[0-9-]\{8,10\}[^ ]*\( *@ *[0-9a-f]\{7,\}\)\?' "$f" 2>/dev/null)
if [ -z "$line" ]; then
printf ' %-44s %s\n' "$(basename "$f")" "MISSING"
continue
fi
d=$(printf '%s' "$line" | grep -o '[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}' | head -1)
age=""
if [ -n "$d" ] && when=$(date -d "$d" +%s 2>/dev/null); then
days=$(( (today - when) / 86400 ))
age="${days}d"
[ "$days" -gt 30 ] && age="${days}d <-- STALE"
fi
printf ' %-44s %-34s %s\n' "$(basename "$f")" "${line#Last verified: }" "$age"
done
fi
# --- tests -----------------------------------------------------------------
if want tests; then
rule "TEST SHAPE"
printf ' benchmarks : %s\n' "$(g -c 'func Benchmark' -- '**/*_test.go' | awk -F: '{s+=$2} END{print s+0}')"
printf ' fuzz : %s\n' "$(g -c 'func Fuzz' -- '**/*_test.go' | awk -F: '{s+=$2} END{print s+0}')"
printf ' table t.Run: %s\n' "$(g -c 't.Run(' -- '**/*_test.go' | awk -F: '{s+=$2} END{print s+0}')"
printf ' golden files: %s\n' "$(g -l 'golden' -- '**/*_test.go' | wc -l)"
fi
# --- gaps ------------------------------------------------------------------
# A package with production code and no test file at all. Not a verdict — some
# are pure wiring — but it is the list worth looking at before adding more.
if want gaps; then
rule "PACKAGES WITH NO TEST FILE"
found=0
for d in $(find ./cmd ./internal ./pkg -maxdepth 2 -type d 2>/dev/null | sort); do
ls "$d"/*.go >/dev/null 2>&1 || continue
ls "$d"/*_test.go >/dev/null 2>&1 && continue
n=$(ls "$d"/*.go 2>/dev/null | wc -l)
l=$(cat "$d"/*.go 2>/dev/null | wc -l)
printf ' %6d lines %2d files %s\n' "$l" "$n" "$d"
found=1
done
[ "$found" = 0 ] && echo " none"
fi
exit 0