Skip to content

Part A of the architecture plan: instrument, pin, fix, extract - #22

Merged
dinooo13 merged 2 commits into
mainfrom
architecture-part-a
Sep 12, 2026
Merged

dinooo13 merged 2 commits into
mainfrom
architecture-part-a

Conversation

@dinooo13

Copy link
Copy Markdown
Owner

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

DictationCoordinator times capture stop, engine, processor pipeline and insert with ContinuousClock and ships a CycleTiming in the .inserted event. AppModel now logs:

release-to-paste 0.312 s: stop 0.012, engine 0.250, process 0.003, paste 0.014; audio 4.2 s

The plan's example format is followed (stop, engine, process, paste), plus engine-time (the engine's own processingTime) so a busy engine actor is visible. docs/BENCHMARKS.md documents the line. Nothing else on the path changed; reading the clock costs nanoseconds.

A2 — characterisation tests

EchoEngine.transcribe now refuses to run before load() 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). Added FlakyEngine and 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 engine at once and unload the old engine, so finish transcribed with an engine that was still loading and the dictation was lost. The cycle now captures cycleEngine at press and uses it; the replaced engine is unloaded only after the cycle ends (pendingUnloads, drained at every cycle exit). A becomeIdle() helper replaces the four copies of the end-of-cycle state logic, and the force unwrap in finish is gone.

A4 — build the pipeline on settings change, not per dictation

finish used to call makePipeline(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 in init and rebuilt in settingsChanged; AppModel defines processorFactories and the DictionaryReplacer placeholder-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 DictationCoordinator into EngineLoader, a Foundation-only @MainActor class, with its own tests (loadReachesReady, loadFailureReportsTheEngineMessage, reloadAfterFailureRecovers, selectReturnsThePreviousEngineAndLoadsTheNext). The coordinator keeps the state machine; its public init is unchanged. grep pollStatus\|statusTask in the coordinator returns nothing.

A6 — documentation

CLAUDE.md pluggability rules now describe EngineLoader and the settings-time pipeline. docs/ARCHITECTURE-REVIEW.md is committed alongside the plan as history (the plan leaves the choice to the developer; it can be deleted if unwanted).

Verification

  • swift build and swift test pass: 94 tests in 10 suites.
  • Critical-path note: A1, A3 and A4 touch code between recordingStopped and inserted. The bench only measures the engine, so it is expected not to move.

Bench after (M1, six runs, median):

Fixture This branch Baseline (docs/BENCHMARKS.md)
10s 0.264 s 0.237 s
30s 0.415 s 0.399 s
60s 0.588 s 0.563 s
2m 0.925 s 0.908 s
5m 2.031 s 1.976 s
10m 3.797 s 3.704 s

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 on inFlight), because at settings-change time during a recording inFlight is still nil — the cycle has not been created yet — so the old engine was unloaded before the release ever transcribed with it, and A2's EchoEngine correctly threw NotLoaded. The fix: the engine used by the cycle is captured at press (cycleEngine), and unloading a replaced engine is deferred until the cycle ends via pendingUnloads, drained at every exit of finish/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)

  • The plan's "Done when" for A1 requires confirming the new log line appears for a real dictation, and A3 asks for ten log lines before and after. Please dictate once and check:
    /usr/bin/log show --last 10m --style compact --predicate 'subsystem == "de.dinooo13.pladder"'

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.
@dinooo13
dinooo13 merged commit 544b21b into main Sep 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant