The vendored toolchain lives inside the module tree, so `go mod tidy` walked
Go's own compiler-error fixtures and died on files that are malformed on
purpose ("unicode//utf8": double slash). A nested module is not part of its
parent, so deps/go.mod ends the walk in three lines. deps/ is gitignored, so
the sentinel is generated by `make deps-sentinel`, which deps-go and deps now
depend on.
The tidy it makes possible drops github.com/kami/praxis, which no file
imports — Praxis is reached over HTTP, by contract. Its replace directive and
the unused nexus one went with it, so a build no longer expects two sibling
checkouts that nothing reads. vendor/ is committed, so `make tidy` re-vendors
in the same breath: a tidy alone leaves the next build failing on
"inconsistent vendoring".
Not wired into `make test`. A build target that rewrites go.mod is a surprise.
Verified: `make build` produces all 9 binaries and `make test` is green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
make build now catches a broken supervisor on homesrv. deploy/mavgpud.json
carries the owner's gemma-4-12b line with the MTP draft model, passed to
llama-server untouched. The unit is a systemd user unit because sudo on the
workstation wants a password; lingering is the one command left to the owner.
Seventeen markdown files at the repo root, twelve of them dated one-shot
reports sitting next to CLAUDE.md. That is why stale docs read as
current: nothing in the path said which was which.
Root now keeps CLAUDE.md and AGENTS.md. Living docs move under docs/
and carry a Last verified line. Dated measurements move to docs/evals/
ISO-prefixed, and are never edited after the day, so a newer number is
a new file. The senior review moves to docs/archive/.
Every reference was rewritten across markdown, Go comments, the Makefile
and the recall fixture. The touched Go packages still build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file comment named four regressions caught here. Three were not.
Nothing on this path resamples, because PCMFromWAV refuses anything that
is not already 16 kHz mono s16. Nothing exercises language selection,
because the hint comes out of the manifest already correct. And a bad
model path was the one condition that made the whole test vanish behind
a skip nobody reads. The comment now claims the two things that are
real, an explicitly set MAVEN_WHISPER_MODEL that does not exist is a
failure, and a missing fixture is a failure rather than a skip.
looseWordMatch accepted a different word. Four retained runes of "воды"
is "вод", so whisper hearing "выпил водки" satisfied the ru_fact
keyword, and "dis" let display, distance and discuss all stand in for
"disk". A case ending adds a rune, not a syllable, so the hypothesis is
capped in length as well as matched on prefix.
The spoken text lived in the generator and in the manifest with nothing
tying them together. Editing one left the other describing audio that no
longer existed, and at a flat ceiling of 0.34 over a five-word reference
a one-word drift passed silently. The script reads text out of the
manifest now, and the ceilings are set just above what each case really
measures against ggml-small, with the measurement recorded beside them.
Also: the test carried its own copy of the PCM to float32 conversion, so
a regression in the daemon's copy left the silence-gate assertion green,
and the manifest was validated for keywords but not for text, where an
empty reference makes every hypothesis score a WER of 1.
Found in review of #75.
A scenario is a JSON file under cmd/mavend/testdata/scenarios: a start
instant, a script of canned model answers, and a list of steps at "HH:MM".
Each step does one thing — say, audio, signal, arrive, tick, fault — and
then asserts on what she said, what was sent, which ecosystem services were
called, and what landed in the intake journal.
Between those boundaries the real components run: the real router cascade
(stage0, the LLM router over a scripted completer, the classifier
underneath it), the real store, the real reactive handler, the real tick
loop, and the same intake-decorated ipc.CoreAPI the daemon wires. What is
faked is only what a test cannot have: the model, the microphone, the
speaker, the delivery sink, and the ecosystem HTTP services.
Time is a single fakeClock threaded into every reader — the handler, the
intake publish stamp and tick(ctx, now) — so there is no time.Now() on the
replay path and a scenario is reproducible. TestSimulatorIsDeterministic
enforces that by replaying twice and diffing the transcripts byte for byte;
advanceTo refuses a step that goes backwards.
Two scenarios ship. morning_missed replays #284's own description: he
appears at the desk, a feed item, a mail candidate and a relayed
notification arrive through the morning, two ticks pass, and the assertions
are as much about nothing being sent at him unprompted as about what she
said. evening_degraded picks up the tier-2 pipeline case #288 deferred
here — a golden WAV through the STT seam to a written fact — and then puts
the ecosystem into 503 and checks that the proactive loop stays quiet and
that intake keeps working without it.
This is test-only code. Nothing in the production binaries changed, so the
daemon behaves identically when no scenario is running.
`make simulate` runs them verbose so the transcript is readable; `make
test` runs them with everything else.
Vikunja #284
Four committed WAV fixtures go through the real whisper.cpp binding in
cmd/mavsttd, so a wrong model, a wrong language hint, a broken resample
or a regressed silence gate fails `make test` instead of surfacing as
Maven mishearing him.
The fixtures are piper-synthesised, not recorded: scripts/gen-stt-fixtures.sh
drives the vendored piper with the ru_RU-irina voice Maven already speaks
with, so nothing of the owner's voice is committed and every fixture is
reproducible. 360K total for three Russian clips and one English.
Matching is tolerant on purpose. Golden transcripts move with the model,
so each case asserts intent-carrying keywords (prefix match, so Russian
inflection does not fail it) plus a word error rate ceiling, not an exact
string. The matcher is unit-tested on its own and needs no model.
TestGoldenAudioTranscription skips when models/stt/ggml-small.bin is
absent, so `make test` still passes on a box without models.
TestGoldenFixturesAreCanonical runs everywhere and checks the WAVs are
16k mono s16le and would clear mavsttd's own silence gate.
internal/update applies a new build of Maven to the box she runs on and
undoes it when the new build does not come up. cmd/mavupdate is the only
trigger: a CLI the owner runs on the host.
Apply is health-check the running daemon, snapshot the deployed artifacts,
make build, make test, install, restart, health-check — and restore the
snapshot on any failure. The order is load-bearing:
- The preflight health check refuses to update a daemon that is already
not answering. Without a working baseline, a failed update and a box
that was already broken are indistinguishable, and the rollback has
nothing to prove itself against.
- The snapshot is taken BEFORE the build, because make build writes its
binaries into the working tree and on the docker deployment the tree
is the install dir — snapshotting afterwards would snapshot the new
artifacts and leave nothing to roll back to.
- Verification is make build plus make test, before anything is
deployed, so a broken tree costs time and nothing else. A failed
verify also puts the tree's artifacts back, so a later restart by
hand cannot deploy code that failed its own tests.
- The rollback depends on nothing that just changed: byte-for-byte
copies out of the snapshot dir, sha256-verified on the way in, and
the same restart command. No build, no migration, no cooperation from
the code being replaced. It also runs on an uncancellable context —
a rollback interrupted halfway is worse than the failure that caused
it. When the restore itself fails it says so and names the directory
to copy back by hand rather than reporting a tidy rollback.
Off unless configured, and the refusals are code, not documentation. The
daemon does not import this package: there is no IPC method, no web route,
no timer and no act that can start an update, so nothing Maven says or
routes reaches it. Nothing fetches code — the new version is whatever the
owner pulled into the tree. The plan's release checker, auto-update
channel and in-process crash-loop supervisor are deliberately absent; a
process cannot reliably notice that it keeps dying, and restart-on-crash
belongs to compose or systemd. The database is never snapshotted or rolled
back; schema compatibility stays store.Migrate's job.
The config is refused at load without a health socket, since an update
that cannot check its own result cannot roll back, and refused when the
snapshot dir is inside the install dir, since a restore must not read from
what the install writes.
Vikunja #249
The extraction seam landed on the previous branch but nothing fed it. This
adds the daemon that does: every interval it opens one mailbox read-only
(EXAMINE + BODY.PEEK, so reading leaves no \Seen behind), fetches the UIDs
it has not handed over yet, and posts each message to core over
ingest_mail. Core runs the model and writes task candidates; this daemon
writes nothing and cannot create a reminder.
It is a separate daemon because of the credential. mavpoll set the
precedent with the zenmoney token (#125): the module talking to the third
party holds the secret, reads it from a file so it never lands in argv, in
docker-compose.yml or in shell history, and core never sees it. There is
deliberately no -password flag, and a test asserts that.
Off unless configured at both ends: without -password-file the daemon
refuses to start, and if core has no email block the first ingest returns
ErrUnknownMethod, which disables the reader instead of hammering a socket
that will keep refusing. A seen-UID state file (0600, atomic write) keeps a
restart from re-extracting the whole lookback window; correctness does not
depend on it, since capture dedupes on normalised text. Logs are counts and
UIDs — no subject, sender or body.
Verified with an in-process IMAP server and a fake core: bulk mail is
filtered before core is asked, seen UIDs are not re-fetched, a failed
ingest is retried next poll, ErrUnknownMethod stops at the first message,
and state survives a restart. The live half is untested by design — no IMAP
credential exists on this box; setup is written up as QA steps.
Vikunja #246
The phrasing fixture was 15 nudge cases, so every prompt change we
measured only told us about nudges. But the shared context block sits in
front of five prompts, and three of them — chat, note query, general
knowledge — had no scorer at all. Those are the long free-form replies,
where a persona break is most likely and where nothing could see one.
27 cases, nine per path. Nine rather than five because the nudge fixture
already cannot resolve a change smaller than about three cases, and a
per-path score off five would be worse.
Reuses the persona checks instead of copying them. Length, mood and
"no questions" are left out on purpose: these paths return no mood, and
a follow-up question is a feature in chat, not a fault.
The run refuses to score unless the model answers before and after it.
PhraseChat and PhraseQuery swallow model errors and return a canned
string, so without that guard a dead server produces a full report with
zero errors and a bad score — which reads as bad phrasing rather than as
nothing measured. Vikunja #397 is the real fix.
The old model was a symmetric paraphrase model, so it scored "do these
look alike" instead of "does this note answer this question". Also fixes
the file mismatch: the Makefile, the deploy config and both evals now all
name the same quantized file, and the quantized one is what gets measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
DESIGN.md has always said `make test` is "gofmt + vet + -race, no
exceptions". It only ever ran the tests, which is how nine files drifted
out of format without anyone noticing.
`test` now depends on `fmt-check` and `vet`. Checked that fmt-check does
fail when a file is unformatted, so the gate is real and not decorative.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
Review internal/phraser/eval/checks.go -- it IS the measurement. Each check
names in a comment which DESIGN.md line it defends: length, feminine
self-reference (windowed around "я" so the operator's own masculine
second-person forms are not flagged), the cringe list (pet names, emoji,
"!!", fake concern, apology, emotional support, asking how he feels,
praise), on-topic, mood enum. No send/veto signal anywhere, per
DESIGN.md § "Rules decide, LLM phrases".
Fixture (158 lines) and tests (252) do not count toward the diff ceiling;
the scorer itself is still ~650. Splitting eval.go from checks.go would
give two commits neither of which measures anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
Measures whether Maven can find the right note again from a paraphrased
question. Review internal/memory/recalleval/recalleval.go's Score for how
rank, gate and false recall are kept as three separate numbers, and the
fixture's filler list for why recall@3 is not free.
Fixture JSON is generated data and does not count toward the diff limit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
The bake-off in #278/#250 needs two models' scores side by side, and the
report names only carried the config, so the rows were indistinguishable.
ModelID reads /v1/models instead of taking a string that goes stale.
New target: make eval-models MAVEN_LLM_URL=...
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
The onnxruntime .so was already vendored at deps/onnxruntime-linux-x64-1.26.0
— nothing to download. make eval-router now defaults MAVEN_ONNX_LIB there, so
both baselines run by default and only a fresh clone without deps/ falls back
to the hash ratchet alone.
Prod-representative result, deployed 0.55 gate: 28/76 (36.8%), RU 25/61,
EN 3/15, hard 0/11 → 4/11, p50 31ms / p95 71ms. Versus the hash floor's
13/76 at p50 9µs.
The finding is not the accuracy, it's the refusal lane: missed clarifies went
0 → 5 of 6. Better embeddings raise cosine everywhere, so the 0.55 threshold
that used to hold ambiguous utterances back stops holding — "сделай это"
routes to act at 0.847, "бэкап" to chat at 0.755. The gate was implicitly
tuned to the hash floor's low similarities. That is an argument about the
threshold, not about the embedder, and it lands before #320 rather than after.
Also fixes a fixture-model mismatch: ReminderGrammar deliberately skips the
extractor at stage 0 and the daemon's applyAction parses the time downstream
(stage0.go says so). Charging the router for that slot made 4 exact-match wins
read as misses; they are now counted as SlotsDeferred instead. Hash baseline
moves 13/76, ratchet to 0.15.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
#319 asks for a measurement before #320 flips the route decider from the
classifier cascade to the resident model. There was nothing to measure
against: the only routing tests assert single utterances, and the
classifier's seed corpus is its own training set — scoring it there
measures memorisation of frozen centroids, which is the illusion that hid
the weak RU query handling in the first place.
internal/router/eval is a separate package so both paths can be scored
from outside router (including cmd/mavend, where the real llama-server
client lives). The fixture is embedded; the scorer takes a Router
interface, so *router.Router and a bare LLM stage both go through the same
76 cases.
The fixture is a CONTRACT, not a snapshot: cases the cascade fails today
stay in the file and fail loudly. TestFixtureIsHeldOut enforces that no
utterance appears verbatim in models/seeds/*.txt.
Baseline, hash embedder at the deployed 0.55 gate: 9/76 (11.8%), 63 false
clarifies, 0 missed clarifies, p50 9µs. Almost everything falls to the
confidence gate — the documented floor behaviour, not a new bug. The
number worth comparing is TestONNXBaseline's (skipped without
MAVEN_ONNX_LIB); the assertions here are a regression ratchet plus a tight
bound on the dangerous direction: ambiguous utterances must not start
being routed confidently.
Seeding is order-fixed on purpose — a few phrases appear under two intents
and map iteration handed them to a different centroid each run, which made
the score jitter between 9 and 10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
`make test` failed with `go: no such tool "covdata"` on the two packages that
have no test files (cmd/mavenclient, cmd/mavend/seedtest), even though every
package passed. A permanently-red test target trains you to ignore the exit
code, which is the signal protecting the whole suite.
The cause was not a truncated download. Go >=1.24 ships only 7 prebuilt GOROOT
tools; covdata, pprof, test2json, nm, objdump and trace ship as source and are
built on demand. `go tool covdata` has that build-on-demand fallback, but the
`-coverprofile` merge path goes through base.Tool(), which only stats
pkg/tool and exits. deps/go was go1.23.4 against a `go 1.25.5` directive, so
GOTOOLCHAIN=auto re-exec'd into a downloaded toolchain module and inherited
the gap.
deps/go now holds a checksum-verified go1.25.5 with those 8 tools built in
from its own source. Pin GOTOOLCHAIN=local so a future go.mod bump cannot
silently re-exec into a tool-poor module toolchain again, and add a deps-go
target so the install is reproducible rather than hand-placed — which was the
point of vendoring it. pprof works now too, which matters on a
latency-sensitive box.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
New cmd/mavwaked — always-on voice listening client that:
- Captures PCM from arecord subprocess (16kHz mono int16)
- Runs energy-based VAD in 30ms windows (RMS threshold, adaptive floor)
- Buffers utterances (300ms min speech, 800ms silence end, 10s max)
- Sends complete utterances as PushToTalk with Surface=SurfaceVoice (L0)
- Plays reply audio through aplay subprocess
- No new CGo/onnxruntime deps — pure Go
- 10 VAD tests with -race (speech detect, silence, max duration, reset, adaptive floor)
- Makefile build-waked target + Dockerfile integration + alsa-utils runtime dep
- VoiceConfig: add QueryMinScore (default 0.55) + Persona config fields
- voice.go: remove queryMinScore const, wire from cfg.Voice.QueryMinScore
as reactiveHandler field
- llmphraser.go: add Persona to Config, prepend to system prompts in
chat and query paths (systemPrompt/querySystemPrompt methods)
- main.go: pass personaFromCfg into both phraser config blocks
- Makefile: add download-embedder target (Xenova/paraphrase-multilingual-
MiniLM-L12-v2, ~90MB ONNX)
- AGENTS.md: document embedder model download + libonnxruntime setup
- server.go: fix pre-existing wg.Add vs wg.Wait data race using accept
mutex. make test green, zero races across all 29 packages.
- test target now runs with -race and emits coverage.out for coverage
reporting.
- New build-caldav target builds cmd/mavcaldav without CGO (pure Go).
- build-caldav added to PHONY and build dependency chain.
- mavcaldav added to clean target.