Stop the boundary scorer's distance penalty overruling a better pause - #11
Merged
Conversation
`boundaryScore` subtracted the raw distance from the target. Linear, unbounded, and in the same units as nothing else in the score, so it dominated every quality term: on the shipping policy a clean 1.3 s sentence break ten seconds early scored below a 0.4 s breath sitting on the target, and the splitter took the breath. Normalising by the width of the acceptable window fixes the units. Distance still breaks ties, so chunks stay evenly sized, but it can no longer overrule a much better pause inside the range the policy already called acceptable. Measured over the 60 retained recordings past the splitting threshold: the median pause a cut lands in goes from 0.76 s to 1.32 s and cuts landing in a pause of a second or more from 40% to 60%, with the same number of chunks and a slightly shorter final chunk. There is nothing to tune here — the old form was wrong rather than differently weighted. Writing the test found a second, worse bug. A record *struct* ignores its primary constructor's defaults for `new()` and zero-initialises instead, so the C# `DefaultPolicy` was every field zero: no minimum chunk length, a zero target, a zero horizon that emptied the preferred set on every call, and a minimum pause of zero that made a single 20 ms dip a legal cut. That client has been splitting long recordings at the first quiet frame while Swift and Kotlin aimed at 60 seconds. Every existing test passed, because they assert that cuts land in silence and that no audio is lost, and both stay true when the chunks are tiny. Fixed with an explicit parameterless constructor and pinned in all three cores. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015XKX6zEAGiZFE5wAxJgYbV
The same mistake as the two files fixed in #18, caught before merging this time: kotlin.test is not on this project's test classpath. JUnit4 also wants a delta on a double comparison and puts the message first, so those are corrected too. Verified with ./gradlew --offline :app:testDebugUnitTest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015XKX6zEAGiZFE5wAxJgYbV
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.
The scorer bug
boundaryScoresubtracted the raw distance from the target:That term is linear, unbounded, and in the same units as nothing else in the score, so it dominated every quality term. On the shipping policy a clean 1.3 s sentence break ten seconds early scored below a 0.4 s breath sitting on the target — and the splitter took the breath.
Normalising by the width of the acceptable window fixes the units. Distance still breaks ties, so chunks stay evenly sized, but it can no longer overrule a much better pause inside the range the policy already called acceptable.
Measured over the 60 retained recordings past the splitting threshold:
No trade to tune — better seams, same chunk count, slightly shorter final chunk. The old form was wrong rather than differently weighted.
The worse bug the test found
C# record structs ignore their primary constructor's defaults for
new()and zero-initialise instead:AudioChunker.DefaultPolicy = new()therefore meant the Windows core ran with every field zero: no minimum chunk length, a zero target, a zero horizon that emptied the preferred set on every call, and a minimum pause of zero that made a single 20 ms dip a legal cut. That client has been splitting long recordings at the first quiet frame while Swift and Kotlin aimed at 60 seconds.Every existing test passed. They assert that cuts land in silence and that no audio is lost — both stay true when the chunks are tiny. Fixed with an explicit parameterless constructor, and the policy is now pinned field-for-field in all three cores so it cannot drift silently again.
BoundaryPolicyis the only affected type;TokenUsageis a record class, wherenew()does apply defaults.Tests
New behavioural tests go through
bestBoundaryrather than the private scorer, because the outcome is the contract and the formula is not. Verified they actually discriminate: reverting to the old form fails exactly the one test that captures the change, and passes the three invariants (tie-breaking, eligibility before the minimum, no-cut-without-a-pause) — which is what those are for.One fixture note worth keeping: a synthetic clip whose only quiet is the pause under test estimates its 2nd-percentile floor from speech itself and then finds no speech at all. Real dictation is 39–86% pause, so the fixtures include ordinary background pauses.
529 Swift, 457 C#, zero failures. Kotlin runs in CI.
Not in this PR
Using Silero for boundary placement (rather than only as a speech gate) measures better again — median cut pause 2.14 s and 77% landing in a pause ≥ 1 s — but it needs incremental streaming state to avoid re-running the model over the same buffer every 200 ms in the live path. Separate change.
🤖 Generated with Claude Code
https://claude.ai/code/session_015XKX6zEAGiZFE5wAxJgYbV