37 KiB
Maven Current-State Audit
Date: 2026-09-05 Scope: Read-only code + tests investigation. No inference from names or docs. Source of truth: code and tests only.
A. Current End-to-End Flow Diagram
Telegram Bot API (long-poll)
|
| api.Chat(ctx, "telegram:<id>", text)
v
mavweb POST /api/chat --> ipc.Client (unix socket) --> daemonAPI.Chat()
|
| chatFn = handler.handleText
v
Voice TCP :9100 --> HandlePushToTalk --> STT --> +------------------+
| runTurn() |
| voice.go:270 |
+------------------+
| 0. decision record (ctx)
| 0b. turnRoute (computed once)
| 1. expired clarify notice
| 2. resolveConfirm (y/n)
| 3. resolveRepair (correction)
| 3b. resolveUntargetedRepair
| 3c. resolveCommandProhibition
| 4. resolveClarifyAnswer
| 5. resolveQuietToggle
| 5b. resolveSnooze
| 5c. resolveAck
| 5d. resolveReminderCancellation
| 5e. resolveCandidate (ordinal)
| 6. ROUTE (cascade)
| 7. dialogue merge (followUpMerge)
| 8. clarify (missing slots)
| 9. applyAction (per-intent dispatch)
| 10. replier (LLM or stub)
+------------------+
|
v
reply string
The routing cascade (step 6) in detail:
utterance
|
v
Stage 0: Grammars (stagezero.go:24-91)
| 22 ordered regex/structural grammars. First match wins.
| Confidence = 1.0, Stage = 0. fillMatchedSlots runs.
| NO MATCH -> fall through
v
Stage 0b: Routing Heads (heads.go, ONNX)
| 4 linear heads over mean-pooled e5-small: intent, destination, slot BIO, clarify
| Below headsThreshold (0.6) -> decline, cascade continues
| Intent head softmax -> Decision{Intent, Confidence, Source, Clarify}
| completeParsedSingleVerbFact can overrule clarify
| NO WIRE / ERROR / DECLINE -> fall through
v
Stage 1a: LLM Router (llmrouter.go, Qwen3-1.7B via llama-server)
| GBNF grammar-constrained JSON output
| gateLLMDecision: thin confidence (0.3) for incomplete slots
| ERROR / PARSE FAIL / "unknown" -> fall through
v
Stage 1: Nearest-Centroid Classifier (classifier.go)
| Cosine similarity over ONNX embeddings against seed centroids
| Best intent wins (ties broken by name)
v
Stage 2: Slot Extraction (slots.go:55-91)
| Dispatch on intent: DateTimeParser (reminder), ActMatcher (act), FactParser (fact)
v
Stage 3: Confidence Gate (router.go:244-248)
| Confidence < threshold (0.55) -> Clarify = true, Stage = 3
B. Package/File Ownership Map
Command packages (cmd/)
| Package | Binary | Role |
|---|---|---|
| cmd/mavend | mavend | Core daemon: DB, IPC, routing, actions, voice server, tick loop |
| cmd/mavweb | mavweb | Web UI + HTTP server (serves /, /dash, /history, /trace, /notifications, /tools) |
| cmd/mavwaked | mavwaked | Wake-word + voice activity detection (silero VAD, energy threshold) |
| cmd/mavsttd | mavsttd | Speech-to-text daemon (whisper.cpp / remote worker) |
| cmd/mavttsd | mavttsd | Text-to-speech daemon (piper) |
| cmd/mavgpud | mavgpud | GPU proxy daemon (workpc inference) |
| cmd/mavcaldav | mavcaldav | CalDAV sync daemon |
| cmd/mavmaild | mavmaild | Mail ingestion daemon |
| cmd/mavpoll | mavpoll | Polling daemon (Kuma monitors) |
| cmd/mavenclient | mavenclient | CLI client |
| cmd/mavupdate | mavupdate | Self-update tool |
| cmd/mavseal | mavseal | Seal/encryption tool |
| cmd/e2eprobe | e2eprobe | End-to-end probe |
| cmd/labelgen | labelgen | Label generation tool |
| cmd/mavend/seedtest | (test helper) | Test seeder |
Internal packages relevant to routing/action
| Package | Key files | Role |
|---|---|---|
| internal/router | router.go, intent.go, stage0.go, stagezero.go, heads.go, llmrouter.go, classifier.go, slots.go, source.go, embedder.go, onnxembedder.go, question.go, singletoken.go, notecapture.go, claim.go (package-level), decisiontrace.go | Routing cascade, intent taxonomy, slot extraction, confidence |
| internal/claim | claim.go | Band/Claim/arbitration types (imported by router and dialogue) |
| internal/dialogue | session.go, clarify.go, pending.go, pending.go | Session state, clarification, PendingAction/Capability |
| internal/tool | tool.go, risk.go | Tool execution, risk tiers, policy |
| internal/phraser | phraser.go, llmphraser.go, fallbacks.go, replier.go | Response phrasing (LLM or stub) |
| internal/voice | server.go, wire.go, replier.go | Voice TCP server, wire protocol |
| internal/memory | store.go | Vector memory (cosine similarity search) |
| internal/llm | remote.go | Two-tier LLM client (resident + workstation) |
| internal/lexicon | lexicon.go | Closed Russian word sets (embedded JSON) |
| internal/morph | morph.go | Russian morphology (golem lemmatizer) |
| internal/loop | loop.go | Proactive nudge rules, gates |
| internal/store | facts.go, reminders.go, tools.go | SQLite persistence |
| internal/ipc | coreapi.go, api.go | IPC interface + wire types |
| internal/modes | modes.go | Mode inventory (30 routing classes) |
| internal/mcp | manager.go, client.go, allowlist.go | MCP tool discovery and execution |
| internal/smarthome | client.go | Home Assistant service calls |
Key cmd/mavend files (the "glue" layer)
| File | Responsibility |
|---|---|
| voice.go | runTurn() pipeline, reactiveHandler, replySystem, applyAction |
| turnroute.go | turnRoute memo (computed once per turn) |
| actions.go | actionHandlers dispatch table (7 intents) |
| actions_act.go | actionAct: tool matching, ecosystem interception, execution |
| actions_fact.go | actionFact: write fact + embed + vector insert |
| actions_reminder.go | actionReminder: parse time + create reminder |
| actions_note.go | actionNote: write note + embed + vector insert |
| actions_query.go | actionQuery: 20+ source chain, queryTurn, queryWalk |
| confirm.go | resolveConfirm: y/n for destructive acts, Hexis, routines |
| clarify.go | askClarify, resolveClarifyAnswer, wantedSlots |
| followup.go | followUpMerge: slot inheritance across turns |
| continuation.go | continuationDecision: elliptical follow-ups |
| boot.go | Daemon wiring: connects all pieces |
| voicewire.go | Voice server wiring: STT, router, replier, tools, sessions |
| tick.go | Proactive tick loop: nudge delivery, reminders, routines |
| tick_routines.go | Routine firing, pattern detection |
| tick_digest.go | Digest queue and flush |
C. Ingress -> Routing -> Action Call Trace
Text path (web/telegram)
1. mavweb POST /api/chat (cmd/mavweb/chat.go:84)
-> strings.TrimSpace(r.FormValue("text"))
-> core.Chat(ctx, "web", text) (ipc/coreapi.go:205-213)
2. daemonAPI.Chat (cmd/mavend/tick_api.go:95)
-> chatFn(ctx, conversation, text)
-> handler.handleText(ctx, conversation, text) (voice.go:245)
3. handleText (voice.go:245-248)
-> runTurn(withDialogueID(ctx, ...), text, sourceText)
4. runTurn (voice.go:270-481) -- see Section A for step-by-step
Voice path (mic)
1. Voice TCP Server receives PushToTalk frame (internal/voice/server.go:187-215)
-> handler.HandlePushToTalk(ctx, req, sid) (cmd/mavend/voice.go:200-218)
2. HandlePushToTalk:
-> stt.Transcribe(ctx, req.Audio) -- whisper.cpp or remote worker
-> runTurn(ctx, text, sourceVoice) -- same pipeline as text
-> tts.Synthesize(ctx, replyText) -- piper
-> return voice.PushToTalkResp{ReplyText, ReplyAudio}
runTurn step-by-step (voice.go:270-481)
Step 0 (276): decision.Record installed on context (V-564)
Step 0b (292): turnRoute computed once, shared via context
Step 1 (313): clarifyExpiredNotice -- parked question TTL ran out
Step 2 (318): resolveConfirm -- y/n for parked destructive act
-> classifyConfirm(text) (confirm.go:264)
-> confirmResolvers chain (confirm.go:114):
1. pendingRoutineConfirm
2. pendingHexisExec
3. pendingAct -> tools.Exec(ctx, fn, args, true)
Step 3 (327): resolveRepair -- "нет, это был вопрос"
Step 3b (334): resolveUntargetedRepair -- "нет, не так"
Step 3c (344): resolveCommandProhibition -- "не отменяй..."
Step 4 (356): resolveClarifyAnswer -- answer to parked question
Step 5 (366): resolveQuietToggle -- "тихий режим"
Step 5b (374): resolveSnooze -- "не сейчас" / "потом"
Step 5c (381): resolveAck -- "готово"
Step 5d (389): resolveReminderCancellation -- cancel verb + noun
Step 5e (397): resolveCandidate -- "второй" (ordinal)
Step 6 (406): ROUTE
-> turnRoute.resolve(ctx) (turnroute.go:68)
-> continuationDecision(prev, text, now) OR router.Route(ctx, text, now)
Step 7 (425): followUpMerge(prev, dec, now) -- slot inheritance
Step 8 (445): clarify -- missing required slots
-> hexisBeforeClarify -- try Hexis before asking
-> askClarify -> park PendingQuestion
Step 9 (466): applyAction -> actionHandlers[dec.Intent]
Step 9b (474): ackFromFact -- close live nudge
Step 10 (477): replier -- phrase the reply (LLM or stub)
Action dispatch (voice.go:496-504 -> actions.go:48-56)
applyAction(ctx, dec):
if dec.Clarify -> return "" (Replier phrases)
actionHandlers[dec.Intent](h, ctx, dec):
IntentFact -> actionFact (actions_fact.go:17)
-> coreAPI.WriteFact + memStore.Insert + detectPattern
IntentReminder -> actionReminder (actions_reminder.go:16)
-> coreAPI.CreateReminder
IntentAct -> actionAct (actions_act.go:17)
-> refusesCommand check
-> matcher.Match (fuzzy prefix over enabled tool names)
-> task_status intercept
-> praxis intercept
-> hexis intercept (resolve entity -> discover capabilities -> risk -> exec)
-> proposeGap (if no match)
-> tools.Exec (tool/tool.go:156):
LookupTool -> RiskOf -> PolicyFor(tier) -> dispatch:
MCP -> mcp.CallPositional
HA -> smarthome.CallService
Process -> exec.CommandContext
IntentChat -> actionChat (actions.go:58)
-> phraser.PhraseChat(ctx, utterance, history)
IntentSystem -> actionSystem (actions.go:77)
-> replySystem: keyword match on utterance
IntentNote -> actionNote (actions_note.go:24)
-> coreAPI.WriteNote + memStore.Insert
IntentQuery -> actionQuery (actions_query.go:varies)
-> querySources chain (20+ sources, first claim wins)
-> queryWalk narrows by destination
D. Inventory of Existing Machinery
D1. Deterministic fast-path recognizers
Status: EXISTS, extensive, production-critical
| Component | File:line | What it does |
|---|---|---|
| 22 stage-0 grammars | router/stagezero.go:24-91 | Ordered regex/structural rules. First match wins at confidence 1.0 |
| Grammar type | router/stage0.go:20-29 | Pattern+Build (regex) or Decide (structural) |
| Wake-word strip | router/stage0.go:66-78 | StripWakeToken: removes "мавен" in any script |
| Act allowlist fast path | router/stage0.go:55,80 | "мавен, restart nginx" -> act at stage 0 |
| Command prohibition | stagezero.go:30 | "don't restart nginx" -> refusal sentinel |
| System time/date | stagezero.go:35 | "сколько времени", "который час" |
| Agenda query | stagezero.go:39 | "что у меня сегодня" |
| Reminder | stagezero.go:59 | "напомни через час" with time extraction |
| Fact capture | stagezero.go:80 | "запиши купить молоко" with note body extraction |
| Possession statement | stagezero.go:89 | "у меня кончилась вода" |
| Lexicon (closed word sets) | lexicon/lexicon.go | ~40 embedded Russian word sets |
| Morphology | morph/morph.go | Lemma(), IsVerbForm(), SameWord() via golem |
| Question detection | router/question.go | IsQuestionShaped(), IsOpenQuestionShaped(), CarriesCaptureVerb() |
| Single-token analysis | router/singletoken.go | thinSingleToken() with completeSingles escape |
| Note capture parser | router/notecapture.go | ParseNoteCapture(): strips capture frame |
| DateTimeParser | router/slots.go:19-21 | Interface; production is dateparser (not shown) |
| DefaultFactParser | router/slots.go:154-180 | Lemma-based fact extraction (water/meal/shower/break/sleep) |
| DefaultActMatcher | router/slots.go:106-149 | Exact phrase prefix + aliases, longest-first |
D2. Learned routing/NLU
Status: EXISTS, multi-layered
| Component | File:line | What it does |
|---|---|---|
| RouterHeads (ONNX) | router/heads.go:55-64 | 4 heads over e5-small: intent, destination, slot BIO, clarify |
| RouterHeads.Route | router/heads.go:120+ | Softmax classification, threshold=0.6, single forward pass |
| LLMRouter | router/llmrouter.go:20-22 | GBNF-constrained JSON from Qwen3-1.7B |
| LLMRouter.Route | router/llmrouter.go:120+ | System prompt + grammar -> routeAction structs |
| gateLLMDecision | router/router.go:352-373 | Thin confidence (0.3) for incomplete slots |
| Classifier | router/classifier.go:40-45 | Nearest-centroid over ONNX embeddings |
| Classifier.Classify | router/classifier.go:50+ | Cosine similarity, sorted best-first |
| Classifier.AddExample | router/classifier.go | Append-only correction (grows classifier) |
| ONNX Embedder | router/onnxembedder.go | multilingual-e5-small, 384-dim, query/passage prefix |
| HashEmbedder | router/embedder.go:79-116 | Fallback bag-of-words (deterministic, weak) |
| Embedder interface | router/embedder.go:17-21 | Dim(), Embed(), Close() |
| Intent taxonomy | router/intent.go:38-63 | 7 intents: act, reminder, fact, note, query, chat, system |
| Source taxonomy | router/source.go | 12 destinations: recall, calendar, tasks, list, money, weather, home, network, feeds, attention, self, world |
| Slots struct | router/intent.go:66-94 | Time, Fn, Args, Key, Value, Text + Has* flags |
| Decision struct | router/intent.go:100-128 | Utterance, Stage, Intent, Confidence, Slots, Clarify, Source, SourceAnchored |
| Modes inventory | modes/modes.go | ~30 distinct downstream behaviors, embedded JSON |
| Confidence gate | router/router.go:244-248 | threshold=0.55, below -> Clarify=true |
D3. Claim/arbitration system
Status: EXISTS, not wired into cascade (per claim.go:138-140)
| Component | File:line | What it does |
|---|---|---|
| Band enum | claim/claim.go:38-69 | BandUnknown, BandVetoed, BandNearest, BandStructural, BandAnchored |
| Claim struct | claim/claim.go:88-117 | Claimant, Intent, Filled, Consumed, Unexplained, Band, Veto |
| Coverage() | claim/claim.go:122-128 | Consumed / (Consumed + Unexplained) |
| MoreSpecificThan() | claim/claim.go:141-147 | Coverage first, Band breaks ties |
| Tokens() | claim/claim.go:156-169 | Tokenize utterance for coverage |
| Split() | claim/claim.go:178-192 | Partition tokens into consumed/unexplained |
Note: claim.go:138-140 explicitly states this is "Deliberately NOT wired into the cascade by V-565." It is here so the ordering is one function with tests rather than duplicated logic.
D4. Action path
Status: EXISTS, with multiple dispatch paths
| Component | File:line | What it does |
|---|---|---|
| actionHandlers table | actions.go:48-56 | 7-intent dispatch map |
| applyAction | voice.go:496-504 | Short-circuits on Clarify, dispatches via table |
| actionAct | actions_act.go:17-114 | Full act cascade: refuse -> match -> task_status -> praxis -> hexis -> propose -> exec |
| tool.Executor.Exec | tool/tool.go:156-235 | LookupTool -> RiskOf -> PolicyFor -> dispatch (MCP/HA/process) |
| RiskOf | tool/risk.go | Derives tier from Tool row |
| PolicyFor | tool/risk.go | TierSafe/TierDestructive/TierIrreversible -> Confirm/VoiceMayRun |
| Matcher.Match | tool/tool.go:251+ (tool package) | Fuzzy prefix over enabled tool names |
| MCP dispatch | tool/tool.go:191-198 | mcp.ParseCmd -> mcp.CallPositional |
| HA dispatch | tool/tool.go:205-221 | smarthome.ParseCmd -> smarthome.CallService |
| Process dispatch | tool/tool.go:231-234 | exec.CommandContext (no shell) |
| Hexis integration | ecosystem_acts.go:660+ | resolve entity -> discover capabilities -> risk -> confirm/exec |
| Praxis integration | ecosystem_acts.go (handlePraxisAct) | Attention/item lifecycle |
| UnknownTargetError | tool/tool.go:90-96 | Named error with target word |
| Tool store | store/tools.go | ProposeTool, EnableTool, DisableTool, LookupTool, ReconcileMCPTool |
D5. Confirmation/risk handling
Status: EXISTS, comprehensive
| Component | File:line | What it does |
|---|---|---|
| resolveConfirm | confirm.go:78-100 | classifyConfirm + chain of resolvers |
| classifyConfirm | confirm.go:264+ | Closed yes/no lexicon, entire utterance must match |
| confirmResolvers | confirm.go:114-186 | 3 slots: routine proposal, Hexis exec, local tool |
| confirmTTL | confirm.go:59 | 90s |
| pendingAct | confirm.go:48-54 | fn, args, phrase, expiry |
| pendingHexisExec | confirm.go:21-35 | capabilityID, entityID, correlationID, expiry |
| pendingRoutineConfirm | confirm.go:39-46 | routineID, action, object, interval, phrase, expiry |
| park() | confirm.go:63-67 | Stores pendingAct |
D6. Non-action paths
| Component | File:line | What it does |
|---|---|---|
| actionChat | actions.go:58-75 | phraser.PhraseChat with dialogue history |
| replySystem | voice.go:509-569 | Keyword matching on utterance for time/date/status |
| actionQuery chain | actions_query.go:99-188 | 20+ sources, first claim wins |
| queryWalk | actions_query.go:190+ | Narrows chain by destination |
| Best recall | recall.go:8-65 | Vector search with confidence gate (minScore + minMargin) |
| Clarify store | dialogue/clarify.go | PendingQuestion stack (max depth 2), 90s TTL |
| Follow-up merge | followup.go:99-194 | Slot inheritance across same-intent turns |
| Continuation | continuation.go:57-98 | Elliptical follow-ups ("а завтра?") |
| Tick loop | tick.go | Proactive nudge delivery, reminders, routines, patterns, digest |
D7. Tests/data inventory
Status: Extensive (354 test files)
Key test fixtures:
| Fixture | File | Cases |
|---|---|---|
| Routing contract | internal/router/eval/ru_routing_v1.json | ~70 held-out utterances with intent/source/time/clarity expectations |
| Ecosystem reach | internal/router/eval/ru_ecosystem_v1.json | ~50 act utterances with service/capability expectations |
| Nudge phrasing | internal/phraser/eval/nudges_v1.json | 15 cases, on-topic + property checks |
| Talk phrasing | internal/phraser/eval/talk_v1.json | 27 cases (chat/query/knowledge) |
| Recall contract | internal/memory/recalleval/ru_recall_v1.json | ~40 cases with note sets |
| Personal boundary | cmd/mavend/testdata/personal_boundary_v1.json | 72 cases |
| Safety scenarios | cmd/mavend/testdata/system_safety_scenarios.json | 4 scenarios |
| Simulator scenarios | cmd/mavend/testdata/scenarios/*.json | 5 full scripted scenarios |
| Usage transcript | scripts/testdata/usage-turns.txt | 167-line simulated usage |
| STT golden | cmd/mavsttd/testdata/golden_v1.json | 4 cases with WER bounds |
Key eval harnesses:
| Harness | File |
|---|---|
| Routing eval | internal/router/eval/eval.go |
| Heads eval | internal/router/eval/heads_test.go |
| LLM router eval | internal/router/eval/llmrouter_test.go |
| Ecosystem reach eval | internal/router/eval/reach_test.go |
| Claim scoring | internal/router/eval/claims_test.go |
| Nudge phrasing eval | internal/phraser/eval/eval.go |
| Talk phrasing eval | internal/phraser/eval/talk.go |
| Recall eval | internal/memory/recalleval/recalleval.go |
| Kiwix rewrite eval | internal/kiwix/rewrite_eval.go |
| Simulator | cmd/mavend/simulator_test.go |
Classifier seed phrases: models/seeds/{query,fact,chat,act,note,reminder,system}.txt
E. Existing Contracts/Types We Can Reuse
Already well-typed (reuse as-is or thin wrapper)
| Type | File:line | Notes |
|---|---|---|
router.Intent |
intent.go:38 | String enum: act, reminder, fact, note, query, chat, system |
router.Slots |
intent.go:66-94 | Typed: Time, Fn, Args, Key, Value, Text + Has* flags |
router.Decision |
intent.go:100-128 | Utterance, Stage, Intent, Confidence, Slots, Clarify, Source, SourceAnchored |
router.Source |
source.go | String enum: 12 destinations |
router.Grammar |
stage0.go:20-29 | Pattern+Build or Decide |
router.Extractor |
slots.go:45-49 | Time, Acts, Facts parsers |
claim.Claim |
claim.go:88-117 | Band-based arbitration (not wired yet) |
claim.Band |
claim.go:36-69 | Ordinal evidence kinds |
dialogue.PendingAction |
pending.go:58 | Capability, slots, missing, utterance, TTL |
dialogue.Capability |
pending.go:16-46 | 7 capability strings |
tool.Executor |
tool/tool.go:116-122 | Exec with policy checks |
tool.Policy |
tool/risk.go | Confirm, VoiceMayRun per tier |
ipc.ChatReply |
ipc/api.go:752-761 | Reply, Source, TraceID |
ipc.Tool |
ipc/api.go:699 | Wire shape of tool row |
Partially exists (needs extension)
| Concept | Current form | Gap |
|---|---|---|
| NormalizedInput | Raw string in, text string parameter |
No NormalizedInput struct; STT output passes as-is |
| FastPathResult | Stage-0 grammar Decision | Not a separate type; embedded in Decision |
| RouteDecision | router.Decision |
Already carries Stage (0/1/2/3), Intent, Confidence, Slots. Could become RouteDecision |
| ActionCandidate | router.Decision + actionHandlers dispatch |
No explicit ActionCandidate type; intent + slots + handler selection are implicit |
Missing (must be created if needed)
| Concept | Notes |
|---|---|
| Schema validation | No JSON-schema or struct validation on incoming slots before execution |
| Confidence/risk policy | Risk tiers exist for tools but not for routing confidence. Stage 3 gate exists but is a simple threshold |
| Post-execution verification | No explicit verification step after tool execution (success/failure is the extent) |
F. Gaps Against the Proposed First-Stage Design
Proposed pipeline vs current reality
| Proposed stage | Current state | Gap |
|---|---|---|
| NormalizedInput | Raw text string everywhere |
No normalization struct. Preprocessing is scattered: StripWakeToken in router, lowercase in matchers, trim in entry points |
| Deterministic fast-path | Stage-0 grammars (22 rules) | EXISTS and is production-critical. However: grammars produce Decision directly, not a separate FastPathResult type. No schema validation on slots before returning |
| Learned routing | Heads -> LLM -> Classifier cascade | EXISTS and complex. But: multiple confidence scales (stage-0=1.0, heads=softmax, LLM=1.0/0.3, classifier=cosine). No unified confidence model |
| RouteDecision | router.Decision |
EXISTS under another name. Carries Stage, Intent, Confidence, Slots, Clarify, Source. Could be wrapped/renamed |
| ActionCandidate | Implicit in router.Decision + actionHandlers |
Missing as explicit type. The decision arrives at applyAction and is dispatched by intent. No schema validation of slots before dispatch |
| Schema validation | NONE | Slots are filled by extractors and used directly. No validation that e.g. reminder has both Text and Time before actionReminder runs |
| Confidence/risk policy | Threshold gate (0.55) for routing; risk tiers for tools | No unified confidence policy. Routing confidence and tool risk are separate systems. No policy that says "if confidence < X, require confirmation for action" |
| Confirmation | resolveConfirm with 3 pending slots | EXISTS for destructive tools, Hexis, and routines. Not applied to routing confidence (a low-confidence act just gets proposed, not confirmed) |
| Execution | tool.Executor.Exec | EXISTS with MCP/HA/process dispatch. Well-structured. But: actionFact, actionReminder, actionNote bypass tool.Executor entirely (they call CoreAPI directly) |
| Post-execution verification | Success/failure error handling in actionAct | Partially EXISTS. Tool execution returns (out, err). Error types are handled specifically. But: no structured verification step, no retry policy, no rollback |
Architectural problems identified
-
Fast paths that execute directly: actionFact, actionReminder, actionNote call CoreAPI.WriteFact/CreateReminder/WriteNote directly from the action handler, bypassing tool.Executor. This means they skip the risk tier system, the confirm gate, and the allowlist. This is by design (facts/reminders are user-stated, not tool invocations) but means the "all actions through one pipeline" goal requires either wrapping these in tool-like abstractions or explicitly exempting them.
-
Multiple confidence scales: Stage-0 = 1.0 (hardcode), heads = softmax float, LLM = 1.0 or 0.3 (thin), classifier = cosine similarity. These are not on the same scale and cannot be compared. The claim system (claim.Band) explicitly addresses this by making confidence ordinal (Band) rather than graded. The proposed design should preserve this insight.
-
Routing code that also performs tool selection: actionAct at actions_act.go:29-33 runs the matcher inline when HasFn is false. The matcher is also the stage-2 ActMatcher. So tool selection happens both in the router (stage 2) and in the action handler (actionAct). The actionAct path is the fallback for LLM-routed acts where the verb didn't go through stage-0.
-
Implicit fallthrough: The router cascade is explicitly designed as fallthrough (each stage may decline). The query source chain is also fallthrough (first claim wins). The confirm resolver chain is also fallthrough. This is a consistent pattern, not a bug, but means the "stage-to-stage" architecture must preserve explicit decline semantics.
-
Clarify bypasses action pipeline: When dec.Clarify is true, applyAction returns "" immediately (voice.go:497-499). The clarifier can also call hexisBeforeClarify (voice.go:446) to try Hexis before asking, which is a hidden action path that runs before the normal action dispatch.
-
No schema validation: Slots filled by stage-2 extraction or stage-0 grammars are used directly by action handlers. actionReminder (actions_reminder.go) checks HasTime itself. actionFact checks HasKey. But there is no shared validation layer; each handler does its own checks.
-
Voice-specific behavior: HandlePushToTalk wraps runTurn with STT before and TTS after. The turnSource tag ("tap:voice" vs "tap:text") propagates into fact sources but does not change routing or action behavior. However: the voice path has barge-in, session management, and wake-word detection that the text path lacks entirely. The semantic behavior is the same; the infrastructure is different.
-
Existing useful code to preserve:
- Stage-0 grammars: 22 ordered rules, battle-tested, each with extensive comments about why it sits where it sits. Moving or reordering them breaks routing.
- The claim/Band system: explicitly designed for the problem of incomparable confidence scales. Not wired yet but well-tested.
- The clarify store with stack support: handles nested clarification flows.
- The query source chain with destination narrowing: 20+ sources with guessers vs lookups distinction.
- The tool risk tier system: well-tested, with voice-specific authority limits.
- The turnRoute memo pattern (V-560): computed once, shared via context, prevents routing disagreement.
G. Smallest Behavior-Preserving Refactor Boundary
The smallest refactor that aligns with the proposed architecture without changing behavior:
Wrap Decision in RouteDecision + add NormalizedInput as thin alias
Current: router.Route(ctx, utterance, now) -> (Decision, error)
Proposed: router.Route(ctx, NormalizedInput, now) -> (RouteDecision, error)
Where:
NormalizedInputistype NormalizedInput struct { Text string; Source string }-- a thin wrapper, not a transformationRouteDecisionistype RouteDecision Decision-- or justDecisionwith a type alias- The existing Stage field (0/1/2/3) already encodes which stage produced the result
- The existing Confidence field already carries the per-stage confidence
This changes zero behavior. It names what exists. It creates the typed boundary the future stages need.
Second step: extract action candidates
Currently actionAct does tool matching inline. The matcher result (fn, args) should be a typed ActionCandidate returned by the router or by a post-route step, not discovered inside the action handler. But this changes the call structure of actionAct, which is a larger refactor.
Third step: schema validation
Add a Validate(slots) step between routing and action dispatch. Currently each handler validates its own slots; this would centralize it. Minimal behavior change: the same checks, in one place.
H. Recommended Implementation Order
-
Type the boundaries (1-2 hours)
- Define NormalizedInput, RouteDecision as thin wrappers
- Route() signature change (internal callers only)
- Zero behavior change
-
Pin current behavior with regression tests (2-3 hours)
- Run existing eval fixtures and record baselines
- Add integration tests for the full runTurn pipeline (text + voice paths)
- Add tests for each action handler with representative inputs
-
Extract ActionCandidate from actionAct (3-4 hours)
- Move tool matching out of actionAct into a post-route step
- Return ActionCandidate{Fn, Args, Source} from routing
- actionAct consumes ActionCandidate instead of re-matching
-
Add schema validation layer (2-3 hours)
- Validate slots before action dispatch
- Centralize the per-handler checks
- Fail-closed: missing required slot -> clarify, not runtime error
-
Unify confidence presentation (3-4 hours)
- Map per-stage confidence to ordinal Band (leverage existing claim.Band)
- Expose in RouteDecision for downstream policy
- Do NOT try to make confidence comparable across stages
-
Wire claim.Band into cascade (4-6 hours)
- Replace ad-hoc precedence with MoreSpecificThan
- This is the V-558/V-565 work already planned
I. Tests That Should Pin Current Behavior Before Refactoring
High-value regression pins
| Test | What it pins | File |
|---|---|---|
| Router cascade stage ordering | Stage-0 wins, heads decline correctly, LLM fallback, classifier floor | internal/router/router_test.go |
| Held-out routing contract | ~70 utterances with intent/source/time expectations | internal/router/eval/eval_test.go |
| Ecosystem reach contract | ~50 act utterances routing to correct service | internal/router/eval/reach_test.go |
| Simulator scripted day | Full pipeline: STT -> router -> store -> phraser | cmd/mavend/simulator_test.go |
| Safety scenarios | Destructive acts require confirmation, ambiguous entities clarified | cmd/mavend/eval_scenarios_test.go |
| Tool risk assessment | Tier derivation from tool rows | internal/tool/ risk_test.go (implied) |
| Confirm flow | y/n for parked acts, TTL expiry, chain ordering | cmd/mavend/confirm_test.go |
| Clarify flow | Missing slots -> question -> answer -> continue | cmd/mavend/clarify_test.go |
| Follow-up merge | Slot inheritance across turns | cmd/mavend/followup_test.go |
| Query source chain | First-claim-wins, destination narrowing | cmd/mavend/querywalk_test.go |
| Personal boundary | 72-case held-out fixture | cmd/mavend/personalboundary_test.go |
| Recall contract | ~40 cases with paraphrased queries | internal/memory/recalleval/recalleval_test.go |
| Action act risk | Destructive/irreversible classification | cmd/mavend/actions_act_risk_test.go |
| Degradation | Each ecosystem service unreachable | cmd/mavend/ecosystem_degraded_test.go |
What to run before and after each refactor step
make test # full suite
go test ./internal/router/eval/ -run Eval # routing contract
go test ./cmd/mavend/ -run Simulator # integration
go test ./cmd/mavend/ -run Eval # safety scenarios
go test ./cmd/mavend/ -run PersonalBoundary # boundary fixture
go test ./internal/phraser/eval/ -run Eval # phrasing contract
go test ./internal/memory/recalleval/ -run Eval # recall contract
J. Unknowns That Cannot Be Established From Code/Tests
-
Actual production accuracy numbers: The eval fixtures measure held-out accuracy, but production routing traces (routing_traces table) are the real measure. We cannot inspect the production DB from code.
-
Whether the LLM router is currently enabled in production: The config shows
llm_routersettings but we cannot confirm the daemon is running with it wired. The heads may be the actual fast path. -
Real-world confirmation rates: How often do users get asked to confirm? How often do they decline? This is behavioral data, not code.
-
Whether the claim system should be wired: claim.go says "Deliberately NOT wired by V-565" but the current ad-hoc precedence works. The claim system is tested but untested in production.
-
Token budget pressure on the resident model: The 4096 context window is shared between routing, phraser, and chat. We cannot tell from code whether context pressure causes routing failures in production.
-
Whether stage-0 grammars overlap or shadow each other: The ordering is documented, but no test measures "if grammar A were removed, which utterances would fall through differently." The cascade hides contention by design.
-
Performance characteristics of the ONNX embedder in production: Tests measure p50 (20.6ms for classifier). Production numbers on the actual hardware may differ.
-
Whether the ecology of pre-route resolvers (steps 1-5e) can be unified: Seven resolvers each claim the turn independently, in order. Whether they could be replaced by a single arbiter (the claim system) is a design question, not a code question.
Proposed Mapping: Future Stage/Contract -> Current Implementation
| Future stage/contract | Current implementation | Reuse/wrap/move/replace | Reason |
|---|---|---|---|
| NormalizedInput | Raw text string parameter in handleText/HandlePushToTalk/runTurn |
wrap | Create struct, pass through. No transformation needed yet. Existing preprocessing (StripWakeToken, trim) stays inside the router. |
| Deterministic fast path | Stage-0 grammars (router/stagezero.go, stage0.go) | reuse as-is | 22 battle-tested rules with load-bearing ordering. Output is Decision at confidence 1.0. Naming it "fast path" is cosmetic. |
| FastPathResult | Decision with Stage=0 | wrap | Type alias or thin struct. The Stage field already identifies the source. |
| RouteDecision | router.Decision (intent.go:100-128) |
reuse (rename or alias) | Already carries all needed fields: Intent, Confidence, Slots, Clarify, Source, Stage. The Stage field (0/1/2/3) tells which cascade stage produced it. |
| ActionCandidate | Implicit: Decision.Intent + Decision.Slots + actionHandlers dispatch | move | Extract from actionAct into a post-route step. Currently, actionAct:29-33 re-runs the matcher when HasFn is false. This should produce an ActionCandidate that actionAct consumes. |
| Schema validation | Per-handler checks (actionReminder checks HasTime, actionFact checks HasKey) | move + centralize | Currently scattered across action handlers. Centralize into a Validate(Decision) step before applyAction. |
| Confidence/risk policy | Stage-3 gate (threshold 0.55) for routing; RiskOf/PolicyFor for tools | extend | These are separate systems today. The routing gate Clarify flag. The tool policy returns ErrNeedsConfirm/ErrNeedsAuthedSurface. A unified policy would map confidence bands to action policies. |
| Confirmation | resolveConfirm (confirm.go:78) with 3 pending slots | reuse | Already handles destructive tools, Hexis, and routines. Would need extension if low-confidence acts should also confirm. |
| Execution | tool.Executor.Exec (tool/tool.go:156) + per-intent handlers | reuse | Well-structured with MCP/HA/process dispatch. The per-intent handlers (actionFact, actionReminder) bypass Executor by design -- they write to the store, not run tools. |
| Post-execution verification | Error handling in actionAct (actions_act.go:64-108) | extend | Currently: success -> "done", specific error -> specific reply. No structured verification step. Adding one would be a new layer. |