Add a replayable full-system simulator on a fake clock (#284) #79
Closed
claude
wants to merge 1 commits from
overnight/replay-simulator into overnight/event-envelope
pull from: overnight/replay-simulator
merge into: kami:overnight/event-envelope
kami:master
kami:task/725-capability-ledger-and-empirical-baseline
kami:task/692-heads-path-may-equal-model-path-and-noth
kami:task/694-staticcheck-and-deadcode-are-still-not-i
kami:task/682-go-1-25-5-and-x-text-0-14-0-carry-20-rea
kami:task/674-caveats
kami:task/673-mavgpud-serves-the-model-to-the-whole-la
kami:task/487-capture-device-doc
kami:task/487-capture-device
kami:task/487-wake-word-deploy
kami:task/487-wake-word-threshold
kami:task/487-wake-word-stage-two
kami:task/671-mavwaked-registers-as-a-voice-consumer-i
kami:task/670-cut-claude-md-to-200-lines
kami:task/515-deploy-mavwaked-workpc
kami:task/669-prune-claude-md
kami:task/668-e4b-phrasing
kami:task/668-title-capital
kami:task/668-kiwix-answers-a-question-it-cannot-answe
kami:task/666-only-a-stage-0-grammar-may-take-the-pers
kami:task/487-mavwaked-has-no-wake-word-only-an-energy
kami:task/486-deploy-the-workstation-transcriber
kami:task/486-move-stt-and-tts-to-the-workstation-wher
kami:task/665-crisperwhisper-2-russian
kami:task/664-routing-heads-in-go
kami:task/662-usage-harness-source-badge
kami:task/661-post-merge-usage-rerun
kami:task/661-routing-heads-step-3-train-the-multi-hea
kami:task/660-router-prompt-destination
kami:task/659-destination-fixture
kami:task/655-query-source-is-a-routing-decision-made
kami:task/654-a-pending-clarify-has-no-way-out-neither
kami:task/654-week-of-usage-eval-docs
kami:task/649-needs-kami-telegram-is-the-only-reach-an
kami:task/643-memorystore-search-decodes-and-unmarshal
kami:task/641-two-maps-grow-for-the-process-lifetime-w
kami:task/644-mavcaldav-is-built-documented-as-running
kami:task/642-the-store-caps-sqlite-at-one-connection
kami:task/647-factenrichmentworker-walks-the-pending-q
kami:task/646-v-637-follow-up-telegram-intake-has-no-d
kami:task/638-no-deadline-survives-the-turn-path-from
kami:task/637-inbound-telegram-turns-and-corrections-f
kami:task/636-correcting-a-turn-from-telegram-and-from
kami:task/634-an-act-alias-resolves-the-verb-but-not-t
kami:task/630-one-gesture-correction-on-chat-v-628
kami:task/629-persist-the-routing-trace-and-record-it
kami:task/631-mode-inventory-written-from-the-handlers
kami:task/586-defaultfactparser-uses-hand-written-russ
kami:task/633-reconcile-the-seed-labels-with-the-handl
kami:task/627-reminder-verbs-has-no-alarm-verb-so-an-a
kami:task/626-the-classifier-seeds-teach-an-older-inte
kami:task/546-route-with-a-fine-tuned-e5-small-instead
kami:task/586-measure-the-fact-parser
kami:fix/gofmt-ecosystem-acts
kami:task/584-media-store-a-failed-write-leaks-its-bud
kami:task/518-no-write-path-for-a-backdated-event-so-t
kami:task/287-qa-voice-session-quality-polish
kami:task/492-qa-plan-reconcile
kami:task/530-sweep-tail-four-files-the-russian-sweep
kami:task/405-score-how-often-a-real-utterance-reaches
kami:task/529-money-and-list-pick-a-mechanism
kami:task/528-sweep-tail-the-three-files-on-467
kami:task/527-embedder-open-set-phrasings-stop-being-r
kami:task/526-morphology-a-dictionary-answers-the-gram
kami:task/525-lexicons-the-finite-russian-sets-move-to
kami:task/524-entity-reference-ask-nexus-about-every-l
kami:task/523-risk-tiers-take-hexis-s-tier-for-a-hexis
kami:task/521-review-pr-111-query-strings-declension-h
kami:task/491-llama-server-core-dumps-on-every-sigterm
kami:task/479-bug-an-unconfigured-capability-does-not
kami:task/467-bug-spoken-task-capture-is-dead-the-rout
kami:task/463-deploy-mavwaked-and-mavenclient-run-nowh
kami:task/480-hearing-no-shipped-client-can-start-a-re
kami:task/432-ambient-calendar-intake-is-fragile-and-p
kami:task/431-board-surface-maven-holds-the-work-board
kami:task/433-reactivehandler-has-30-fields-and-is-pas
kami:task/371-swap-the-embedder-for-an-asymmetric-retr
kami:task/408-review-31-07-split-the-30-method-coreapi
kami:task/410-review-31-07-hand-rolled-string-enums-st
kami:task/423-review-pr50-split-internal-ipc-server-go
kami:task/422-review-pr50-split-cmd-mavend-tick-go-860
kami:task/409-review-31-07-finish-moving-mavweb-markup
kami:task/482-ambient-ingest-reads-a-notification-s-ti
kami:task/444-kuma-a-fact-per-monitor-so-she-can-name
kami:task/452-capability-model-homelab-docker-restart
kami:task/449-destructive-confirm-policy-risk-tiers-no
kami:task/453-grocery-list-items-table-fourth-append-o
kami:task/399-run-the-persona-checks-inside-the-daemon
kami:task/448-bounded-follow-up-state-pending-candidat
kami:task/455-conversation-repair-name-the-misroute-co
kami:task/454-go-mod-tidy
kami:task/458-pronunciation-dictionary-for-piper
kami:task/456-command-history-read-only-query-over-exi
kami:task/457-clarification-templates-for-the-router-s
kami:task/474-query-source-ordering-feeds-and-calendar
kami:task/469-reminders-spelled-out-times-fail-the-bod
kami:task/475-bug-the-praxis-attention-capability-is-u
kami:task/481-bug-a-transient-complaint-is-stored-as-a
kami:task/476-bug-the-router-transliterates-latin-enti
kami:task/385-decide-whether-a-parked-clarify-question
kami:task/377-backfill-routines
kami:task/421-weather-geocoder
kami:task/390-no-read-path-for-delivery-attempts
kami:task/386-recall-fixture-filler-note-ids
kami:task/473-bug-morning-item-has-no-required-flag
kami:task/465-bug-make-simulate-routes-with-an-empty
kami:task/467-bug-spoken-task-capture-is-dead
kami:task/466-bug-a-pending-clarify-is-global-so-one-u
kami:task/468-bug-pattern-detect-has-no-minimum-interv
kami:task/462-bug-checkfeminine-flags-second-person-ma
kami:task/443-safekey-drops-cyrillic-so-russian-calend
kami:task/471-bug-agendaquerygrammars-covers-today-but
kami:task/383-slottext-in-clarify-answer-would-clobber
kami:task/323-qa-phraser-coverage-is-65-3-but-the-llam
kami:task/498-bug-and-x-reach-the-model-with-no-determ
kami:task/506-strings-family-6-summaries-and-reports-i
kami:task/504-strings-family-4-act-and-smart-home-repl
kami:task/503-strings-family-3-query-answers-and-gaps
kami:task/502-strings-family-2-capture-acknowledgement
kami:task/501-strings-family-1-phrasing-fallbacks-into
kami:task/397-phrasechat-and-phrasequery-hide-model-fa
kami:task/396-the-reply-path-can-t-be-tested-llmreplie
kami:task/496-recall-a-cross-language-question-loses-i
kami:task/495-bug-x-escapes-the-personal-boundary-and
kami:task/499-llama-server-holds-7-9gb-rss-for-a-1-1gb
kami:task/470-bug-a-question-writes-invented-knowledge
kami:task/493-bug-the-memory-index-stores-the-raw-utte
kami:task/490-name-the-gap-world-questions-through-the
kami:task/485-run-the-big-model-on-the-workstation-wit
kami:task/489-workstation-deploy-mavgpud-on-workpc-and
kami:task/488-workstation-a-supervisor-that-keeps-llam
kami:task/483-docs-offload-design
kami:task/483-design-offload-ml-to-the-workstation-kee
kami:task/459-docs-refresh-the-qa-plan-against-the-liv
kami:task/446-doc-reorg-tier-the-tree-retire-the-three
kami:fix/367-voice-parks-routine-accept
kami:task/365-dialogue-slots-and-router-slots-are-hand
kami:task/364-snooze-does-nothing-at-runtime-the-gate
kami:task/447-retire-progress-md-the-backlog-and-the-f
kami:task/445-session-workflow
kami:overnight/eco-versioned-traces
kami:overnight/eco-entity-refs
kami:overnight/eco-degraded-suite
kami:overnight/netscan
kami:overnight/smarthome
kami:overnight/event-envelope
kami:overnight/coldstart-unlock
kami:overnight/voice-barge-in
kami:overnight/stt-golden-audio
kami:overnight/senses-speaker
kami:overnight/senses-hearing
kami:overnight/senses-media-vision
kami:overnight/mcp-tools
kami:overnight/mcp-client
kami:overnight/self-update
kami:overnight/model-swap
kami:overnight/web-crawler
kami:overnight/rss-feeds
kami:overnight/email-poller
kami:overnight/email-extract
kami:overnight/email-imap
kami:overnight/money-zenmoney
kami:overnight/task-priority
kami:overnight/task-capture
kami:overnight/behavior-profile
kami:overnight/day-plan
kami:overnight/ambient-calendar
kami:overnight/local-calendar
kami:overnight/memory-eval
kami:overnight/proactive-proposals
kami:overnight/split-voice-quiet
kami:overnight/nginx-maven-block
kami:overnight/stepup-chat-surface
kami:integration/small-batch
kami:docs/fix-drift
kami:fix/ru-wording
kami:integration/jul31
kami:overnight/resident-1.7b
kami:overnight/nudge-templates
kami:overnight/kiwix-rewrite
kami:overnight/eval-writeup
kami:overnight/fix-truncation
kami:overnight/kiwix-client
kami:overnight/ru-prompts
kami:overnight/external-data
kami:overnight/phrasing-grammar
kami:overnight/talk-eval
kami:overnight/prompt-context
kami:overnight/prompt-address
kami:overnight/eval-label-kill
kami:overnight/delivery-boundary
kami:overnight/address-check
kami:overnight/system-replies-pr
kami:overnight/clock-intent-pr
kami:overnight/embedder-backfill-pr
kami:overnight/embedder-marker-pr
kami:overnight/note-recall-pr
kami:overnight/thinking-off-pr
kami:overnight/dialogue-persist-pr
kami:overnight/persona-2p-pr
kami:overnight/clarify-expiry-pr
kami:overnight/clarify-rework
kami:overnight/phrasing
kami:overnight/bakeoff
kami:overnight/recall-margin
kami:overnight/router-on
kami:overnight/slot-extract
kami:overnight/embedder-e5
kami:overnight/router-refusal
kami:overnight/eval-rerun
kami:overnight/eval-harnesses
kami:overnight/eval-rerun-base
kami:overnight/fmt-gate
kami:overnight/routines-fire
kami:overnight/router-prompt
kami:overnight/away-leak
kami:overnight/recall-eval
kami:overnight/snooze-works
kami:overnight/clarify-wiring
kami:overnight/delivery-tests
kami:overnight/routine-accept
kami:overnight/llm-router-flag
kami:overnight/clarify-data-layer
kami:overnight/loop-rule-tests
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "overnight/replay-simulator"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What changed
A JSON-driven scenario replayer, test-only, in
cmd/mavend/simulator_test.gopluscmd/mavend/testdata/scenarios/.A scenario declares a start instant, a script of canned model answers (routing and phrasing are separate contracts, so an entry carries
routeand/orreply), and a list of steps at"HH:MM". A step does exactly one thing —say,audio,signal,arrive,tick,fault/clear_fault— and then asserts:expect_reply_contains/_lacks,expect_sent_contains,expect_no_send,expect_called/expect_not_called,expect_events,expect_no_events. The negative assertions are step-scoped, because "nothing was sent because of THIS" is the useful question.Real between the boundaries: the router cascade (stage0 → LLM router over a scripted completer → classifier), the store, the reactive handler, the tick loop, and the intake-decorated
ipc.CoreAPIfrom #283. Faked only where a test cannot have the real thing: the model, mic, speaker, delivery sink, and the Praxis/Nexus/Hexis HTTP services (reusing thefakeServerharness from #276).Two scenarios:
cmd/mavsttd/testdata/golden_v1.jsonthrough the STT seam, the router, the store write and the phrasing contract. Then the ecosystem answers 503 and the proactive loop has to stay quiet while intake keeps working.make simulateruns them verbose so the transcript prints;make testruns them with everything else.Why
Tomorrow's QA is otherwise a person talking to a daemon and watching. A scripted day that replays identically makes a regression in the proactive loop, the intake journal or the reply contract fall out of a test run instead of a session. #288 (PR #75) explicitly deferred its tier-2 full-pipeline scenarios here.
Determinism
One
fakeClockfeeds every clock reader in the world — the handler'snow, the intake publish stamp, andtickLoop.tick(ctx, now), which already tooknow. There is notime.Now()on the replay path and the daemon's wall-clock ticker is never started.TestSimulatorIsDeterministicreplays a scenario twice and requires byte-identical transcripts;advanceTofatals on a step that goes backwards, so a scenario cannot silently depend on ordering the clock does not enforce.Production impact
None. Every file added is
_test.goor testdata; the only non-test change is a newsimulateMakefile target. The daemon behaves identically when no scenario is running.Behaviour this pinned down
morning_missedstep 6 documents a real path: a query whose recall finds nothing above the score gate returns the canned"не знаю."and never reaches the replier. That is the no-hallucination floor and the scenario now asserts it rather than expecting a phrased answer.How verified
make simulate— 2 scenarios + determinism + backwards-step guard, all pass, transcripts readable.make build— exit 0.make test— exit 0 (fmt-check, vet,-raceacross./internal/..../cmd/...).Vikunja #284
Making "nothing happened" a first-class assertion is the right instinct.
expect_no_sendscoped to the step rather than to the run is the detail that makes it mean anything. Adding a scenario as a JSON file with no Go change is what will keep this used.DisallowUnknownFieldson the scenario decoder earns its line: a typo inexpect_no_sendwould otherwise turn a negative assertion into silence. The whole thing being test-only, with no flag and no production branch, is the correct shape.1.
expect_not_calledcan pass while the forbidden call happened.callPathsconcatenates per server, praxis then nexus then hexis.callCountis a total.assertthen slices the concatenated list by the total:paths[callsBefore:]. Those two orderings are not the same list.Walk it with all three fakes wired. Before the step, praxis has seen 3 requests and nexus 1, so
callsBeforeis 4 andpathsis[p1 p2 p3 n1]. The step makes one praxis call. Nowpathsis[p1 p2 p3 p4 n1], andpaths[4:]is[n1]. The new praxis call sits at index 3 and is never examined.expect_not_called: ["praxis"]passes on a step that called praxis.The same slice makes the opposite error too.
[n1]is a stale call from an earlier step, soexpect_not_called: ["/resolve"]fails on a step that resolved nothing. Record a single ordered log across all three fakes, or take a per-server snapshot before the step.2. Nothing in either scenario ever calls the ecosystem, so
faultis inert.evening_degradedstep 21:15 says "a tick against a dead ecosystem must degrade, not send half a thought". The tick loop in this world cannot reach the ecosystem.newSimWorldbuilds it withnewTickLoop(st, gatherer, dispatcher, phraser.NewStub(), rules, ...)and no eco argument, matching production, andtick.gomentions praxis nowhere. The only reader ofeco.praxisishandlePraxisAct, which fires on anactdecision whoseFnmatches an allowlisted alias.Neither scenario produces an
act.morning_missedscriptsfact,queryand achatcatch-all.evening_degradedscriptsfactandchat. So the praxis fake receives zero requests in both files, and deleting"fault": 503and"clear_fault": truechanges no assertion outcome. Neither scenario usesexpect_calledorexpect_not_calledeither, and neither wiresnexus_resolveorhexis_capabilities. The two headline capabilities of the harness, "what TOOLS were called" and the degraded-mode lever, have no coverage at all.That matters more than a missing test, because PR 76 built a fault-injection harness for exactly these modes. Script
[{"intent":"act","fn":"..."}]against a 503 praxis. Assert the reply andexpect_no_send. That is the case this file promises and does not have.3.
TestSimulatorIsDeterministicreads the wall clock and will flake.morning_missedlogs transcript lines at 08:30, 08:32, 08:35, 08:40, 08:45, 08:50, 08:55 and 09:00, formatted15:04:05.sc.Startis2026-08-01T08:30:00+03:00, which contains only08:30. Run the suite at 08:35 local andfirstcontains08:35:00,sc.Startdoes not contain08:35, and the test reports "transcript carries the wall clock". That is a 30-minute window per day on a test whose subject is determinism.The first half of the test already proves the property. Two runs of a path with a live
time.Now()in it diverge, andfirst != secondcatches that. Drop the second check, or assert something structural instead, such as every transcript timestamp falling inside the scenario's own span.4. A scenario cannot express a fact below full confidence, and one scenario's note claims it does.
morning_missedstep 08:40 is annotated "a relayed notification, below full confidence".simWorld.writehardcodesConfidence: 1.0, andsignalStephas no confidence field. The ambient path writescalendar.AmbientConfidence, 0.6. So the simulated ambient arrival is not the ambient path, and the note on the step is false.This is a live gap against #283 rather than a cosmetic one.
factPriorityinintake.gois the only consumer ofConfidence, and 1.0 versus 0.6 is exactly the branch it takes. A replay cannot reach the low branch, so no scenario can pin what the journal does with priority. AddconfidencetosignalStep, default it to 1.0, and set 0.6 on that step.Smaller notes:
TestSimulatorRefusesBackwardsStepsdoes not test a backwards step. It advances to 09:00, then to 09:30, and asserts the clock moved. The name and the doc comment both promise the guard. Take afatalf func(string, ...any)onsimWorldso the backwards branch is reachable from a test.ExpectEventsandExpectCalledare run-scoped while their negatives are step-scoped. That is documented, but it means a scenario author who repeatsexpect_events: ["rss:tech"]on a later step is asserting nothing new. Worth saying in the step doc comment, not just in the type comment.expect_no_eventscomparesbus.Len(), which saturates at the ring capacity of 512. A long scenario that fills the ring makes every subsequentexpect_no_eventspass unconditionally. Compare the newest event instead, or count publishes with a subscriber.audioTextre-reads and re-parsesgolden_v1.jsonon every audio step. Two steps is fine, a scenario with twenty is not.expect_reply_lacks: ["рад "]is checking a literal in the Go source. The 08:55 check["записал,"]depends on a comma and passes on "записал что ты выпил воды".Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.
Pull request closed