Score how often a real utterance reaches Hexis/Praxis (ecosystem reach fixture) #160

Merged
claude merged 21 commits from task/405-score-how-often-a-real-utterance-reaches into master 2026-08-04 18:50:49 +02:00
Contributor

Closes Vikunja #405.

Opened during an unattended overnight run: the diff-budget block was off (task overnight). Read the diff, not only the tests.

Acceptance criteria and quality gate are in TASK.md on this branch.
Review the review, not the diff — leave comments and the agent will apply them via task start 405.

Closes Vikunja #405. Opened during an unattended overnight run: the diff-budget block was off (`task overnight`). Read the diff, not only the tests. Acceptance criteria and quality gate are in `TASK.md` on this branch. Review the review, not the diff — leave comments and the agent will apply them via `task start 405`.
kami approved these changes 2026-08-04 13:19:25 +02:00
Author
Contributor

Reviewed as part of a bottom-up pass over the whole open stack (#119 to #168): commits read against the base branch, make test green at the top of the stack. Nothing to raise on this one. Merging.

Four findings landed on the PRs they belong to, none of them blocking: #167 (a duplicate rest-of-day-query grammar from the merge), #164 (four files the Russian sweep did not reach), #145 (sh -c hides an irreversible verb from the tier derivation), #128 (locationCandidates drops short city names).

Reviewed as part of a bottom-up pass over the whole open stack (#119 to #168): commits read against the base branch, `make test` green at the top of the stack. Nothing to raise on this one. Merging. Four findings landed on the PRs they belong to, none of them blocking: #167 (a duplicate `rest-of-day-query` grammar from the merge), #164 (four files the Russian sweep did not reach), #145 (`sh -c` hides an irreversible verb from the tier derivation), #128 (`locationCandidates` drops short city names).
claude changed target branch from task/463-deploy-mavwaked-and-mavenclient-run-nowh to master 2026-08-04 18:47:10 +02:00
claude added 21 commits 2026-08-04 18:47:10 +02:00
A phone posts an RFC 3339 instant ending in Z, and the clock inside the
text is a wall clock nobody means in UTC. The wall clock used to be
resolved against Posted's own zone, so on this UTC+4 box a 14:30 standup
was stored at 18:30. The size of the error is the deploy's offset, which
is why the tests never saw it: they ran on a UTC box.

EventFromNotificationIn takes the zone explicitly and EventFromNotification
passes time.Local. The day comes from Posted's local day too, since a
notification posted at 23:30Z saying "завтра" is already tomorrow where he
is standing. Posted itself stays an instant, so the past-grace check still
compares instants.

The two handler fixtures said a bare "10:00" against a 09:40Z post, which
is stale once the clock is read locally. They say "завтра" now, so they
mean a future meeting in every zone. internal/calendar and cmd/mavweb pass
under UTC, Europe/Samara, America/Los_Angeles, Pacific/Kiritimati and
Asia/Kathmandu.
The eight pages were already embedded .html files. The shell that wraps
them was not: shellTop and shellBottom were Go string constants, and the
sidebar inside shellTop was assembled by a strings.Builder writing
`<div class=sidebar-section>` a fragment at a time. That builder is the
markup-in-Go the review complained about.

shell.html now holds shellTop, the sidebar it calls, and shellBottom, and
every page composes shellHTML + <page> instead of shellTop + <page> +
shellBottom. Go keeps only the data: sidebarSections, exposed to the
template as a function, and pageIcon, which now returns the symbol id
("i-grid") and lets the template write the <use> reference once instead of
fourteen times.

sidebarActive was dead — nothing called it.

Verified by rendering /dash before and after and diffing: the markup is
byte-identical apart from a newline between sidebar sections.
/tools, /routines and /chat were the only pages whose markup still lived in
a Go string constant. They are tools.html, routines.html and chat.html now,
embedded exactly like the eight that already were, so no page markup is
left in Go and the "HTML in Go" complaint is answered with no framework, no
build step and no second artifact.

routineRow/routineRows are routineView/toRoutineViews. The pattern is right
— it maps wire structs to display structs so a template never formats an
interval or a timestamp — but "rows" read like database rows when these are
view models. Checked the other half of that review thread while renaming:
handleRoutines calls the mapper once and formats nothing itself, so there
is no duplicated work between the handler and it.

Content is verbatim. htmx is deliberately not added here; per the task it
comes later and only where a page wants partial updates.
860 lines had grown to 1094. It splits where the function names already
said it would:

  tick.go          the loop driver, the tick itself, phrase repeat, tuner
  tick_digest.go   the queue, the flush window, the drain
  tick_routines.go configured routines, accepted ones, pattern detection
  tick_morning.go  the checklist windows and the day plan
  tick_api.go      daemonAPI and the loop-to-ipc conversions

Move-only, same package. Verified mechanically, not by eye: the set of
top-level declarations is unchanged, and the 991 non-blank body lines of
the old file are the same multiset as the five new ones concatenated. Only
the per-file headers and the trimmed import blocks are new text.

--no-verify: 1485 changed lines against a 300-line cap. A move cannot be
split under it — every line counts twice, once deleted and once added, and
a half-moved file does not compile. The cap is there to keep a commit one
reviewable idea, and this is one idea: nothing changed but which file each
function sits in, which is exactly what the multiset check above proves.
server.go was two unrelated things glued together: the sqlite-backed
CoreAPI adapter, which knows nothing about a wire, and the dispatcher,
which is all wire. The adapter and its five store-to-ipc converters plus
mapErr are storeapi.go now, 455 lines. server.go keeps Server, the method
table, the three methods that bypass CoreAPI, and the connection handling,
and drops from 1391 lines to 949.

Move-only, same package, no new indirection. Verified the same way as the
tick.go split: the 1262 non-blank body lines of the old file are the same
multiset as the two new files concatenated. s.Check still runs before the
table lookup, at the top of dispatch, so locked mode is untouched.

--no-verify: a move counts every line twice, once deleted and once added,
so it cannot fit the 300-line cap and a half-moved file does not compile.
The multiset check above is what stands in for reviewing it line by line.
Two review threads from PR 4, and the answer to the third.

The routine status was a bare string with its legal set in a comment.
Nothing caught a typo at compile time, nothing enumerated the set for a
test, and a bad value surfaced as a /routines row that neither accepts nor
dismisses. It is a RoutineStatus now, with the three constants, a
RoutineStatuses slice as the single source of truth, and Valid(). Listing
by an unknown status is refused with ErrRoutineStatus instead of answering
"no rows", which is what a correct query says about an empty table. A
round-trip test moves a routine into each state and reads it back, so a
constant that drifts from the inline SQL fails loudly.

The hand-rolled framing stays, and frame.go now says why: ninety lines,
readable with socat, and every standard replacement brings schema
machinery this boundary does not want. What was wrong was inheriting it
untested. frame_test.go covers the paths a real socket produces and the
round-trip test never does — truncated header, truncated body, one byte
per Read, two frames back to back, and a non-JSON body. Empty input is the
only EOF.

The unanswered question in the same file is answered in place: a routine
object stays a local string, not a Nexus ref, because nothing acts on it.
It is the word he used, replayed back to him, compared only against itself
for the UNIQUE key. Canonical refs arrive if a routine ever drives a Hexis
call, which is V-272.

The mood enum has the same shape and is not done here: it is spelled in
the GBNF grammar, three prompts and the parse, so it is its own change.
Folded into #408 from the same review. mapErr hand-maps eight store sentinels
to wire twins so a module can errors.Is without importing internal/store. The
design is right; the failure mode is silent. Add a sentinel to store, forget
the switch, and the client gets an untyped error no caller can branch on.

Three tests. The pairs, asserted through a wrap because every real caller
wraps. An unrecognised error, asserted to pass through untouched. And the
parity half: parse internal/store with go/ast for exported `var Err* =
errors.New(...)` and require each name to be either mapped or listed in
unmappedStoreErrors with the reason it stays store-side. Nine are listed —
the two crypt errors never cross CoreAPI, and the routine and task ones are
caller bugs or input validation, not states a module recovers from. A tenth
sentinel added tomorrow is in neither list and fails, which is the point:
whether a module can branch on an error is a decision, not a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The task names three costs of the flat 40-method interface. Two were already
paid off by earlier work on this train: the 947-line dispatcher is a table
(methodTable, V-423), and UnimplementedCoreAPI took the padding out of every
test double and out of lockedAPI, which no longer exists — cmd/mavend/main.go
now hands the pre-unlock server an ipc.UnimplementedCoreAPI{}.

What was left is the interface itself. CoreAPI moves out of api.go into
coreapi.go and is now the composition of FactAPI, ReminderAPI, NudgeAPI,
NoteAPI, ToolAPI, RoutineAPI, TaskAPI and SystemAPI. As a type it is
unchanged: same methods, same signatures, same doc comments, so the wire
contract, the client proxy, the store adapter and every double are untouched.
No other file is edited and `make test` is green, which is the proof. What it
buys is a name per cluster, so a caller that only reads facts can say FactAPI,
and a new method has an obvious home that is not "the bottom of the list".

--no-verify: 323 changed lines against a 300 cap, and it is one move. The
interface cannot be half-moved and still compile, and splitting the domains
across commits would leave CoreAPI naming a type that does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The swap itself already landed: deploy loads
models/embedder/multilingual-e5-small/model_quantized.onnx, and
onnxembedder.go grew EmbedQuery/EmbedPassage with the query:/passage:
prefixes the model was trained with. What was missing is the half of #371
that says "re-run make eval-recall and compare against the recorded numbers",
so nothing in the repo says whether it worked.

It worked, on every axis at once. recall@1 60.0% → 70.4%, recall@3 80.0% →
85.2%, answered after the gate 48.0% → 63.0%, false recall 1/5 → 0/5, and
latency p50 59ms → 23ms because the quantized file is 118MB against the 470MB
fp32 one the old config loaded. The guitar-chords note no longer beats the
docker-logs note.

One premise of the task did not come true and the new doc says so. #371
expected a better retriever to separate the score distributions and make
query_min_score tunable. It did not: right-first top-1 runs 0.791-0.890 and
must-stay-silent runs 0.795-0.835, still overlapping, just higher and
tighter. The margin separates them instead — 0.024 median against 0.002 — and
0.008 is the knee where all five silent cases are silenced at no cost. The
score gate is close to inert now; the margin is the live dial. Neither is
changed here, since #412 is where a sweep belongs.

docs/evals/2026-08-04-recall-e5-small.md is the dated measurement.
rearchitecture.md's "upgrade MiniLM → bge-m3 later" is now done and says so,
CLAUDE.md names the retriever and the prefix rule where it already promises
the embedder never leaves homesrv, and the Makefile comment points at this
eval instead of the one that asked for the swap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review comment asked for basic DI. The answer is the idiom voice.go
already had for capabilities — a cohesive *Wiring struct — applied to a
group that is not a capability toggle, plus the decision written down so
it is a rule and not a habit.

recallWiring holds the embedder, the vector store, the personal boundary
and the two numbers that gate an answer. They sat in three places on
reactiveHandler, with the gate numbers a hundred lines from the store
they gate. Its zero value means no recall, so it is a value, not a
pointer like the optional-capability groups.

dataStore stays out of it. patterns.go, ecosystem_acts.go and confirm.go
use it, so it is not part of this cluster.

docs/handler-wiring.md records the choice, rejects a container or a
wire-style generator outright, defers narrow per-handler interfaces to
the package split that would justify them, and states the constraint the
task named: a wiring change does not ride a feature PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The decision the task asked for. Build it, in a smaller shape than the
task imagined, because most of it is already there: the tasks table, the
capture parse, the recite matcher and the /tasks page all landed under
#130, #129 and #128.

Three findings changed the shape.

The intake form cannot live on the voice path. resolveConfirm is a
binary yes/no slot with a 90-second life, so filling four fields is a
mechanism nobody has written, and the definition of done is the worst
possible field to dictate through whisper. It moves to the page. Voice
captures a line and recites the list; the page turns a candidate into an
open item.

The stage-0 trick stretches to recite and to status change, both of
which are a marker plus a lookup. It does not stretch to intake, and it
does not have to.

A task is write-once except for its status. SetTaskStatus is the only
mutation, so the form has nothing to save into until an edit path
exists. That is now step 2 of four, and it was not in the task text.

The argument stays unbuilt. Same line internal/memory/behavior.go
already drew for habits: she counts a stall and never assesses one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The task's confirmed defect is out of date. 4e4c917 added day words and a
past-grace refusal, so "завтра в 15:00" dates correctly, and 45a5e37
(V-482, this week) fixed a zone bug the task did not know about. What is
left is explicit dates ("5 августа"), which fail safe by being dropped
rather than stored on the wrong day. The task's third question also has
an answer: both readers hedge, plan.go:174 prefixes "похоже, ".

Everything else hangs on one question that this repo cannot answer, so
the doc names it as his: can the relay app read Android's calendar
provider, or only the notification text? A NotificationListenerService
sees a title and a body and cannot know a meeting's real start, so if
that is all there is, free-text parsing here is not a choice. If it can
read CalendarContract, the parser stops being necessary and nothing is
inferred at all. Reading the phone's calendar does not break the design
constraint, which is about holding a work credential on the homelab.

Decision: keep the endpoint, make a structured event the primary shape,
keep the free-text parse as the degraded path, delete only if the relay
is not being built. And do not patch the date parser first — that is the
patch the task explicitly refuses as closure, and it is the wrong order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found running QA 253 on 02-08. Every one of the four failed the same
way: the daemon is right and the step is stale.

253/3 expected mavend to boot with the capture methods unknown when
there is no media block. Validate refuses to start instead
(config.go:1651), which is the better behaviour — a capture config with
nowhere to put the audio is a mistake he should hear at boot.

253/10 expected no :transcript note by default. writeNotes writes one
whenever the summary is empty, ignoring save_transcript, so a dead
llama-server does not lose the meeting. The step was therefore false in
exactly the degradation scenario 253/16 creates. It now says "with a
summary present".

255/5 expected "speaker: enrolment on, recognition BLOCKED". That line
no longer ships. Recognizes() was written as the gate, documented as
one, and never called; calling it turned enabled-with-no-model from a
half-working capability into a refusal, and the three methods are now
absent. docs/plans/10-speaker-recognition.md described the old wiring
and is corrected here too.

252/3 quoted "vision: stored image <id-prefix>". vision.go:199 emits
"vision: stored <id>".

The steps themselves live in the Vikunja tasks and were rewritten there.
docs/qa.md records what changed and why, so the next reader does not
re-derive it from a diff.

The gap that made the steps unrunnable is V-514, not this: no shipped
client can start a recording, so 253 steps 7 to 16 stay blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They appear in no compose file and run as no host process, and the task
asked whether that is a gap to close or a decision to write down. It is a
decision.

The reason is not hardware. homesrv is a Lenovo laptop and
/proc/asound/cards lists its ACP mic array with capture devices, so
passing /dev/snd into a container would work. It would also listen to an
empty room. A wake-word daemon is worth having where he is standing, and
that is not where the server is.

mavenclient is a client by name and design, mavwaked is the gate in
front of it, and the wire already reaches off-box: ipc.Dial takes
tcp://host:port?token=... through the netaddr seam, with the token
checked before internal/ipc sees the connection. So this needs a machine
and a config line, not protocol work.

The honest consequence is worse than the task suggested, and both docs
now say it: the wake word and the VAD gate are covered by unit tests and
by nothing else. QA session 1 step 2 was reworded to claim only what it
checks, which is push-to-talk through /dash. CLAUDE.md listed all nine
binaries with no column for where they run, which is how this went
unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
30 act-shaped Russian utterances, each with the service it must arrive at:
10 hexis, 12 praxis, 8 that must reach neither. The negatives are the half
that matters most — without them a router that sent every turn to Hexis
would score perfectly.

want_capability records which Praxis arm the fn should land on. It is not
scored: asserting it would mean asserting an alias table this package
cannot import.
Scores the ecosystem reach fixture. Same MAVEN_ONNX_LIB deal as eval-router:
without it only the deterministic hash ratchet runs.
Reach mirrors actionAct and hexisBeforeClarify: praxis needs an act plus a
fn slot equal to a capability alias, hexis needs an act plus non-empty text,
and a clarified act with text reaches hexis before the question is asked.

The two miss directions are counted apart because they cost different
things. Missed means he asks again. Overreach means a turn arrived at a
mutating path nobody sent it to, and he never gets asked about that one.

PraxisAliases is a copy of the registry in cmd/mavend. The registry lives in
package main and cannot be imported, and lifting it out is a refactor this
measurement should not be carrying.
TestReachDerivation pins the gate order the scorer depends on, so a change to
actions_act.go that this package no longer mirrors fails here instead of
quietly moving the number.

The hash baseline asserts overreach and nothing else. Accuracy on the hash
embedder measures the confidence gate, not reach. The ONNX run reports: a
threshold invented alongside the first measurement is a guess written down
twice.
Praxis reach is zero on all twelve cases under both embedders, and it is
structurally impossible rather than merely weak: handlePraxisAct dispatches
on fn equality, and no praxis alias can ever enter the fn slot, because that
slot is filled from the deployment's tool allowlist.

Hexis reach is 9/10. All three services are up and answer; both praxis feeds
are empty, so the gap is entirely on Maven's side of the wire.
The two open lines never met: line A landed through #168, so every pull
request from #148 to #160 conflicted with master on six files. This
reconciles them.

Where the two lines fixed the same thing, the better shape wins:

- Ambient time zones (V-482) landed on both sides. Keeps the injectable
  EventFromNotificationIn from this line, plus master's rationale comment.
  Drops master's forced n.Posted.In(time.Local), which defeated the loc
  argument.
- tick.go: master's guardNudge call and say.CountWord edits, moved onto the
  split files this line created. The digest summary now declines through
  say.CountWord inside tick_digest.go.
- voice.go: master's topicIndex field joins recallWiring rather than the
  handler, since it is embedder-backed recall like the personal boundary.
  topics.go and its test read h.recall.topics now.
- mavweb: master's capability and risk columns ported into tools.html, which
  is where this line moved the markup. The Go const is gone.
- Three new store sentinels for list items get the same verdicts the task
  sentinels already carry, in unmappedStoreErrors.

make build: 12 binaries. make test: green. make fmt-check: clean.

--no-verify: a merge of two long lines cannot fit the 300-line budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Contributor

Reviewed as part of a bottom-up pass over the whole open stack. This branch is the head of the second line, so merging it lands #148 to #159 with it.

The two lines never met. Line A landed through #168, and after that every pull request from #148 to #160 conflicted with master. Six files, growing with the index. I pushed one integration merge here rather than resolving the same conflicts thirteen times.

Where both lines fixed the same thing, the better shape won:

  • Ambient time zones (V-482) landed twice. This line added EventFromNotificationIn(n, loc), master did n.Posted = n.Posted.In(time.Local) inside the one function. The injectable zone stays, because a test can name a zone. Master's rationale comment stays with it. Master's forced conversion is gone: it pinned the reading to time.Local and defeated the loc argument.
  • tick.go. Master added a guardNudge call and a say.CountWord summary. This line split the file. Both edits now sit in the file that owns them, and the digest summary declines inside tick_digest.go.
  • voice.go. Master's topics topicIndex field went into recallWiring, not onto the handler. It is embedder-backed recall with the same lifecycle as boundary, which already lives there. topics.go reads h.recall.topics now.
  • mavweb. Master's capability and risk columns are in tools.html, which is where this line moved the markup. The Go const is deleted.
  • Three new store sentinels for list items get the verdicts the task sentinels already carry, in unmappedStoreErrors. Nothing branches on any of them today.

make build produces all 12 binaries, make test is green, make fmt-check is clean.

Four findings from the pass stay open and none blocks this. internal/router/stage0.go has two grammars named rest-of-day-query and the second is dead. internal/router/numwords.go, cmd/mavend/reminderbody.go, cmd/mavend/historyq.go and internal/weather/openmeteo.go still match Russian by hand, which the sweep missed. Worth a follow-up task.

Reviewed as part of a bottom-up pass over the whole open stack. This branch is the head of the second line, so merging it lands #148 to #159 with it. **The two lines never met.** Line A landed through #168, and after that every pull request from #148 to #160 conflicted with master. Six files, growing with the index. I pushed one integration merge here rather than resolving the same conflicts thirteen times. Where both lines fixed the same thing, the better shape won: - **Ambient time zones (V-482) landed twice.** This line added `EventFromNotificationIn(n, loc)`, master did `n.Posted = n.Posted.In(time.Local)` inside the one function. The injectable zone stays, because a test can name a zone. Master's rationale comment stays with it. Master's forced conversion is gone: it pinned the reading to `time.Local` and defeated the `loc` argument. - **`tick.go`.** Master added a `guardNudge` call and a `say.CountWord` summary. This line split the file. Both edits now sit in the file that owns them, and the digest summary declines inside `tick_digest.go`. - **`voice.go`.** Master's `topics topicIndex` field went into `recallWiring`, not onto the handler. It is embedder-backed recall with the same lifecycle as `boundary`, which already lives there. `topics.go` reads `h.recall.topics` now. - **mavweb.** Master's capability and risk columns are in `tools.html`, which is where this line moved the markup. The Go const is deleted. - **Three new store sentinels for list items** get the verdicts the task sentinels already carry, in `unmappedStoreErrors`. Nothing branches on any of them today. `make build` produces all 12 binaries, `make test` is green, `make fmt-check` is clean. Four findings from the pass stay open and none blocks this. `internal/router/stage0.go` has two grammars named `rest-of-day-query` and the second is dead. `internal/router/numwords.go`, `cmd/mavend/reminderbody.go`, `cmd/mavend/historyq.go` and `internal/weather/openmeteo.go` still match Russian by hand, which the sweep missed. Worth a follow-up task.
claude merged commit be3e5cea25 into master 2026-08-04 18:50:49 +02:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kami/Maven#160