Part A of the architecture plan: instrument, pin, fix, extract - #22
Merged
Merged
Conversation
A1: log every stage of the release-to-paste path. The coordinator now times capture stop, engine, processing and insert separately and ships them in a CycleTiming alongside the .inserted event; AppModel logs "release-to-paste N s: stop .., engine .., process .., paste ..". BENCHMARKS.md documents how to read the line. A2: characterisation tests for engine load failure, reload after failure, and hotkey suspension. EchoEngine now refuses to transcribe before load() finishes, like the real engine. FlakyEngine added to the fakes. A3: transcribe with the engine that was ready when the recording started. A settings change mid-recording used to swap the engine under the running cycle and lose the dictation; the cycle now holds its engine, and the replaced one is unloaded only after the cycle ends. A4: build the processor pipeline when settings change, not per dictation. DictionaryReplacer compiled a regex per entry on the main actor on every release. processorFactories in AppModel replace the placeholder-entry special case. A5: extract EngineLoader from the coordinator. The engine lifecycle — building, loading, status polling, swapping — moves to its own Foundation-only class with its own tests; the coordinator keeps the state machine. Its public init is unchanged. A6: CLAUDE.md's pluggability rules describe EngineLoader and the settings-time pipeline. Bench (M1, six runs per fixture, this branch): | 10s | 0.264 s | 30s | 0.415 s | 60s | 0.588 s | | 2m | 0.925 s | 5m | 2.031 s | 10m | 3.797 s | Baseline (docs/BENCHMARKS.md): 0.237 / 0.399 / 0.563 / 0.908 / 1.976 / 3.704 s. All within the runs' noise; expected, the bench only measures the engine. Per-stage figures come from the new A1 log line, which the developer collects from a real dictation.
- finish now drains pending engine unloads via a single defer, so the empty-transcript exit can no longer leave a replaced engine loaded. - The coordinator's registry property is gone; only the loader needs it. - AppModel reads the settings store once in init and seeds both the processor factories and the coordinator from that one result. - EngineLoaderTests reuse the shared waitUntil helper instead of hand-rolled polling loops.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements Part A (steps A1–A6) of
docs/ARCHITECTURE-PLAN.md(committed in this PR). Part B, the speed experiments, is not included: its gates require real dictations, which the developer records.A1 — per-stage release-to-paste logging
DictationCoordinatortimes capture stop, engine, processor pipeline and insert withContinuousClockand ships aCycleTimingin the.insertedevent.AppModelnow logs:The plan's example format is followed (
stop,engine,process,paste), plusengine-time(the engine's ownprocessingTime) so a busy engine actor is visible.docs/BENCHMARKS.mddocuments the line. Nothing else on the path changed; reading the clock costs nanoseconds.A2 — characterisation tests
EchoEngine.transcribenow refuses to run beforeload()has finished, like the real engine (the plan notes the existing engine-change test passed only because Echo ignored its status — after this change that test needed A3, see below). AddedFlakyEngineand three tests: load failure shows the engine's message, reload after failure recovers, suspending the hotkey drops the recording.A3 — transcribe with the engine that was ready at press
A settings change mid-recording used to swap
engineat once and unload the old engine, sofinishtranscribed with an engine that was still loading and the dictation was lost. The cycle now capturescycleEngineat press and uses it; the replaced engine is unloaded only after the cycle ends (pendingUnloads, drained at every cycle exit). AbecomeIdle()helper replaces the four copies of the end-of-cycle state logic, and the force unwrap infinishis gone.A4 — build the pipeline on settings change, not per dictation
finishused to callmakePipeline(settings)on every dictation, constructing every processor and compiling one regex per dictionary entry, on the main actor, on the critical path. The pipeline is now built ininitand rebuilt insettingsChanged;AppModeldefinesprocessorFactoriesand theDictionaryReplacerplaceholder-entry special case is deleted. Test added that a dictionary change after start is used by the next dictation (catches a stale cache).A5 — extract EngineLoader
The engine lifecycle (building, loading, status polling, swapping) moves from
DictationCoordinatorintoEngineLoader, a Foundation-only@MainActorclass, with its own tests (loadReachesReady,loadFailureReportsTheEngineMessage,reloadAfterFailureRecovers,selectReturnsThePreviousEngineAndLoadsTheNext). The coordinator keeps the state machine; its publicinitis unchanged.grep pollStatus\|statusTaskin the coordinator returns nothing.A6 — documentation
CLAUDE.mdpluggability rules now describeEngineLoaderand the settings-time pipeline.docs/ARCHITECTURE-REVIEW.mdis committed alongside the plan as history (the plan leaves the choice to the developer; it can be deleted if unwanted).Verification
swift buildandswift testpass: 94 tests in 10 suites.recordingStoppedandinserted. The bench only measures the engine, so it is expected not to move.Bench after (M1, six runs, median):
All within noise. The per-stage figures the plan asks for come from the new A1 log line, which the developer collects from real dictations.
One deviation from the plan, flagged per ground rule 7
A3's test (
engineChangeWhileRecordingUsesTheEngineThatRecorded) cannot pass with the plan's literal step 2 ("unload the previous engine only after any in-flight cycle has finished" keyed oninFlight), because at settings-change time during a recordinginFlightis still nil — the cycle has not been created yet — so the old engine was unloaded before the release ever transcribed with it, and A2'sEchoEnginecorrectly threwNotLoaded. The fix: the engine used by the cycle is captured at press (cycleEngine), and unloading a replaced engine is deferred until the cycle ends viapendingUnloads, drained at every exit offinish/cancelRecording.loader.select(A5) swaps immediately, as planned; only the unload is deferred. Behaviour matches the plan's intent: the recording is never lost, and the old engine is unloaded once nothing uses it.Not done (needs the developer)
/usr/bin/log show --last 10m --style compact --predicate 'subsystem == "de.dinooo13.pladder"'