Make dictation latency measurable and gated, and cut what the measurements justified - #1704
Open
r3dbars wants to merge 5 commits into
Open
Make dictation latency measurable and gated, and cut what the measurements justified#1704r3dbars wants to merge 5 commits into
r3dbars wants to merge 5 commits into
Conversation
The local sanitizer drops any context key containing "audio" via substring match, which also swallowed every stage timing on the dictation start path: audio_engine_start_ms, audio_tap_install_ms, audio_input_total_ms and seven more were written to events.jsonl as "[redacted-sensitive-value]" in 197 of 197 events. The app measured the slowest user-visible path in the product and then threw the measurement away, so the start-latency tail could not be attributed to a stage. A bare number under a duration/size/rate key carries no identifying content, so keep the value. Both halves of the test matter: the suffix keeps the escape away from identifier-shaped keys, and the numeric check keeps it away from any key whose value is a device or file name — "audio_device_ms" still redacts. Local sink only. Sentry, Analytics, and the reliability packets sanitize mergedContext separately and through their own allowlists, so off-device payloads are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wavData appended two bytes per sample through a generic helper that pays a withUnsafeBytes closure and a Sequence append every time. At the 5-minute dictation cap that is 4.8M round trips. Building one contiguous Int16 block and appending it once measures 21x faster: 2.58 -> 0.12 ms at 10s of audio, 16.8 -> 0.75 ms at 60s, 82.0 -> 3.7 ms at 5 minutes. This runs on the awaited recovery checkpoint, before the model wait, so it is on the path between the user releasing the key and text appearing. Output is byte-identical. Verified over 600,432 adversarial floats — NaN payloads including signaling NaN, +/-infinity, subnormals, values outside [-1,1], -0.0, and every representable round-half tie across the Int16 range — with zero mismatches. The arithmetic is deliberately unchanged; the NaN guard, the clamp, Float(Int16.max), and bare .rounded() are all load-bearing for that. AccessibilityBridge makes five synchronous AX round trips into the frontmost app with no messaging timeout, on the main actor, and gates the dictation overlay's first pixel. A target whose main thread is wedged stalled the overlay for the AX default. Cap it on the application element, which scopes the timeout to messages for that app — deliberately not the system-wide element, where it would replace the process-wide default for every AX call the app makes. 2s matches the value already used in Tools/TranscriptedQA; the job is to bound a hung app, not to fail fast on a slow one. A timeout degrades to the existing placement fallback (anchor rect, then mouse location), which is cosmetic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The only launch measurement in the repo was a single sample taken by build.sh, which cannot separate a regression from scheduler noise — swift-ci.yml already documents a 48% swing on identical commits. And nothing aggregated the raw per-stage timings that events.jsonl has been carrying all along. - bench-launch-latency.sh runs N isolated launches against a copied bundle and reports min/p50/p90/p95/p99/max. Percentiles are nearest-rank so every reported number is a launch that actually happened, and the output says plainly when p99 of a small sample is really just the observed max. - latency-percentiles.py reports the full distribution for every latency key in an events log, sorted by p99 so the stage owning the tail reads first. It also counts keys the sanitizer redacted, so an observability gap shows up as a gap instead of silently missing rows. - bench-all.sh runs the launch, Home-loader, and real-usage benchmarks together and writes JSON per run for comparison. Each section records its own status so an unavailable benchmark reports as skipped rather than aborting the run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
performance-budget.rb has carried budgets for dictation fast-start, request-to-recording, start-to-first-sample, and stop latency for a while, but they only evaluated inside `if required_samples.positive?` — and --require-dictation-fast-start-samples defaults to 0. Passing --events printed the percentiles and enforced nothing, and build.sh never passed --events at all. Scored against this machine's log, the budgets are currently exceeded by 3-4x (fast-start p95 711ms against 250ms, request-to-recording p95 1089ms against 250ms, start-to-first-sample p95 814ms against 350ms). - A budget now scores itself once the log carries MIN_DICTATION_LATENCY_SAMPLES (20). Below that it is skipped rather than failed, because a thin log means "not enough data to judge", not "regression". --require-*-samples now means only "also fail if the samples are missing". - --min-transcription-samples was an assertion, not a floor, so any sparse log hard-failed with "Only 2 transcription samples, need at least 10". That would have broken build.sh on a fresh checkout or after a quiet fortnight. It is now a scoring floor, with --require-transcription-samples added for callers that genuinely need the assertion. - The fallback/retry check gets a threshold flag instead of an implicit zero, and only runs once there is a fast-start population to compare against. - p99 is reported beside p95 everywhere, and a failing run prints the full summary on stdout before the reasons on stderr. stdout is block-buffered when piped, so this needs an explicit flush or the reasons print above the measurements they refer to. A gate you have to act on is useless if failing it hides the numbers. - build.sh passes --events when a local log exists, scoped to the last 14 days because events.jsonl is append-only across app versions. No log means no scoring, so CI and fresh checkouts stay green. TRANSCRIPTED_SKIP_RUNTIME_BUDGET=1 opts out; TRANSCRIPTED_EVENTS_LOG points elsewhere. The ceilings in build.sh are a ratchet, set just above today's measured latency rather than at the targets, so the gate catches any further regression — which is strictly more than the nothing it caught before. The targets remain the defaults in performance-budget.rb, the gap between the two is written down in one comment, and the ceilings may only ever move down. This matches how build.sh already overrides --max-app-mb and --max-launch-interactive-ms for its surface. CI is unaffected: swift-ci.yml does not pass --events. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app binary was never stripped and the vendor frameworks shipped their build-time payload. Measured on this tree: strip -x takes the binary from 30,363,584 to 17,045,424 bytes (12.7 MiB, 43.9%), and the Headers/Modules directories are another 9.7 MiB, nearly all of it Sentry's .swiftmodule. Bundle goes 88.3 -> 65.9 MiB. The strip sits inside the GENERATE_DSYM branch, after the dSYM-exists check. That placement is deliberate: with GENERATE_DSYM=0 there is no dSYM, so the binary's own symtab is the only symbolication source and must survive. -x keeps external symbols and LC_UUID, so the dSYM pairing that register-sentry-release.sh and the packaged-app dSYM-UUID gate depend on is preserved — verified identical UUIDs on the built bundle. The framework prune runs before sign_embedded_payloads, so the signature seals the trimmed contents. The second rm handles versioned bundles, whose top-level Headers/Modules are symlinks into Versions/Current; leaving them dangling trips codesign --verify --strict. Contents/Helpers/* is deliberately not stripped — bundle_mcp_server has no dsymutil step, so stripping it would destroy the only symbolication that exists for MCP helper crashes, to save 0.75 MB compressed. This is installed footprint, not latency. dyld does not page the symbol table, and the DMG delta is far smaller since zlib compresses symbol tables well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
r3dbars
marked this pull request as ready for review
August 25, 2026 01:52
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.
Why
The app's dictation start path is 3-4x over its own latency budget in production, and the check that would have caught it was dark: the budgets in
performance-budget.rbonly evaluated insideif required_samples.positive?,--require-dictation-fast-start-samplesdefaults to0, andbuild.shnever passed--eventsat all. On top of that, every stage timing on that path was being written toevents.jsonlas[redacted-sensitive-value], so the tail could not be attributed to a stage even by hand.Measured on this machine over 176-203 real dictations (v1.1.56):
request_to_recording_msstart_to_first_sample_msdictation fast-startLaunch, the Home loader, and the transcription model are all comfortably inside budget. Dictation start is not, and at p95 that means losing most of a second of speech.
Product Impact
dictationdictation reliabilityWhat changed
Five commits, each independently revertible.
audioby substring, which swallowedaudio_engine_start_ms,audio_tap_install_ms,audio_input_total_msand seven more in 197/197 events. A bare number under a_ms/_s/_hz/_byteskey now survives; anything non-numeric still redacts, soaudio_device_msstays redacted. Local sink only — Sentry, Analytics, and reliability packets sanitizemergedContextseparately through their own allowlists.wavDataappended 2 bytes per sample through a generic helper (4.8M round trips at the 5-minute cap); one contiguousInt16block is 21x faster (2.58 → 0.12 ms at 10s, 82.0 → 3.7 ms at 5 min) and byte-identical.AccessibilityBridgemade 5 unbounded AX round trips on the main actor gating the overlay's first pixel; now capped at 2s on the application element.bench-launch-latency.sh,latency-percentiles.py,bench-all.sh.build.sh.On the gate's thresholds
Turning it on at the real targets fails immediately — that is the finding. So
build.shuses ratchet ceilings set just above today's measured latency (fast-start 900ms, request-to-recording 1300ms, start-to-first-sample 1000ms). It catches any further regression, which is strictly more than the nothing it caught before. The real targets stay as the defaults inperformance-budget.rb, the gap is written down in one comment, and the ceilings may only ever move down. This matches howbuild.shalready overrides--max-app-mband--max-launch-interactive-msfor its surface.Two supporting changes were needed to make that safe:
--min-transcription-sampleswas an assertion, not a floor, so any sparse log hard-failed withOnly 2 transcription samples, need at least 10. That would have brokenbuild.shon a fresh checkout or after a quiet fortnight. It is now a scoring floor, with--require-transcription-samplesadded for callers that want the assertion.A failing run now prints the full summary on stdout before the reasons on stderr (needs an explicit flush — stdout is block-buffered when piped). Scoped to a 14-day window since
events.jsonlis append-only across app versions. No log means no scoring, so CI and fresh checkouts stay green.TRANSCRIPTED_SKIP_RUNTIME_BUDGET=1opts out.How I checked it
scripts/dev/agent-preflight.sh.agents/test-matrix.ymlfor the files changedbash build.sh --no-openbash run-tests.sh— 12,223 passed, 0 failedbash run-integration-smoke.sh— n/a, noSources/Meeting/orSources/TranscriptedCore/changesswift test— n/a, noPackage.swiftor core seam changesbash build-deps.sh --force,SKIP_NOTARIZATION=1 bash build-beta.sh,python3 scripts/dev/check-build-source-lists.py,python3 scripts/dev/check-analytics-emitters.py,ruby -c,bash -non both build scripts--require-transcription-samples 10strip -x30,363,584 → 17,045,424 bytes, app and dSYMLC_UUIDidentical,codesign --verify --deep --strictclean, no leftoverHeaders/ModulesCaveat on the beta build: run as a thin build (
REQUIRE_BUNDLED_PARAKEET_MODELS=0 BUNDLE_DIARIZER_MODELS=0) because this machine has no local Parakeet/diarizer model cache. Strip, framework prune, signing, and dSYM pairing are all verified; model bundling is not.Risk Review
_mskeys). Off-device payloads are untouched.build-beta.shchanges are release path.LC_UUIDpreserved so Sentry symbolication and the dSYM-UUID gate still pass; helper binaries deliberately not stripped..agent-review/visuals/evidence — n/a, no UI changes.Notes
Six of the eight highest-ranked candidates were rejected under adversarial review, and that is worth as much as what landed:
deniedbug.654aa15bunder the defaults that motivated it and breaks two pinning tests. Hands-free is a toggle: copying push-to-talk's still-held gate makes a quick tap fail to stop a running dictation; dropping it spawns phantom dictations whenever ⌥M is held past 140ms. The "win" is the user's own finger dwell, and a 140ms disambiguation timer is slower than a typical 70-120ms tap.AXUIElementSetMessagingTimeouton the paste-confirmation call — that one is on the system-wide element, where it sets the process-global default rather than bounding one call; and the query is a guard, so failing it fast routes into the branch that leaves the clipboard borrowed, turning rare stalls into systematic clipboard clobbering on slow Electron/JetBrains targets.stop_to_mic_stopis 38% of felt stop latency and produces nothing for the user, but the 50ms sleep inside it guards a documented fatal CoreAudio assertion (isSink || tap != nullptr) on a path no CI job exercises.synchronous=OFFon the Home metadata cache — justified by a 200-row benchmark; the real cold-load workload is ~11 rows (meetingLimit = 10) on a background task.markCleanShutdown()and rewritecleanShutdown: false, poisoning a live release-health metric.Known measurement gaps, not addressed here:
dictation_stop_abandonedevent, not by wideningdictation_stop_latency_measured, which the release gate selects on.stop_to_mic_stop_msfuses a queue hop, the engine stop, the 50ms sleep, and a 1.5s CoreAudio restore into one field;paste_prepare_msfuses the AX capture with the pasteboard snapshot. Those are the two largest p99 sources on the stop path and each is reported as a single number.build.sh's launch smoke reported 758ms on builds where every isolated measurement landed at 220-290ms; I could not reproduce it across fresh HOME, fresh bundle copy, fresh code signature, or system load.swift-ci.yml:270already documents a 48% swing on identical commits and suggests min-of-N sampling —bench-launch-latency.shnow does exactly that, so swapping the smoke for it is a small follow-up.Next step worth more than any speculative optimization here: run a few dozen dictations on a build with the sanitizer fix, then read the start-path stage breakdown. It is measurable for the first time.
Agent handoff
COORD_DONE: GREEN | this PR | unredacted start-path timings, 21x WAV encode, bounded overlay AX query, live latency gate in build.sh with ratchet ceilings, release strip+prune (88.3->65.9 MiB), 3 new benchmark harnesses | no GitHub cleanup needed | decisions: ratchet ceilings vs hard-failing on the real targets, and whether to ship the survivor-bias fix as a separate event | checks: agent-preflight, build.sh --no-open, run-tests.sh (12223 pass), build-deps.sh --force, build-beta.sh thin, check-build-source-lists, check-analytics-emitters, ruby -c, bash -n, gate behavior matrix, WAV byte-identity over 600k adversarial floats | smallest next action: collect dictation samples on a build with the sanitizer fix and read the start-path stage breakdown