Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,17 @@ public enum ContextBiasingConstants {
/// - Used in: `VocabularyRescorer+ConstrainedCTC.swift` stopword check
public static let stopwordSpanSimilarity: Float = 0.85

/// Lowest TDT token confidence at which a transcript word counts as confidently heard.
///
/// A vocabulary term gets no context bias against a span whose words were all heard this
/// confidently, so it must beat the span on CTC evidence alone (#967). Below this, misheard
/// names keep the full bias; the 13 #967 dictation clips' correct fixes all sit at 0.70 or less.
public static let confidentWordMinConfidence: Float = 0.99

/// Similarity at and above which a confidently heard span is a close spelling of the term
/// ("Quiltor" / "Quilter", "Rashid" / "Rasyid") and keeps its bias, so the listed spelling still wins.
public static let closeSpellingSimilarity: Float = 0.80

// MARK: - Context Biasing Weights

/// Default context-biasing weight (CBW) per NeMo paper.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,30 @@ extension VocabularyRescorer {
/// repetition. An unrelated neighboring word must not gain that same bonus.
static func isTextSupportedRepetition(_ candidate: CTCMatchCandidate) -> Bool {
if candidate.similarity == 1 { return true } // Exact alias or compound spelling, including BK-tree matches.
func lexicalCore(_ text: String) -> String {
let normalized = normalizedLexical(text)
if normalized.hasSuffix("'s") { return String(normalized.dropLast(2)) }
if normalized.hasSuffix("s'") { return String(normalized.dropLast()) }
return normalized
}
let original = lexicalCore(candidate.originalPhrase)
let forms = [candidate.vocabTerm] + (candidate.matchedAlias.map { [$0] } ?? [])
return forms.contains { StringUtils.levenshteinDistance(original, lexicalCore($0)) <= 1 }
}

/// Whether the span is a close spelling of the term or its matched alias, possessive aside ("Quiltor's" /
/// "Quilter", "Rashid" / "Rasyid"), at `ContextBiasingConstants.closeSpellingSimilarity`: a spelling
/// preference, where the listed form should win even over a confident decode.
static func isCloseSpelling(_ candidate: CTCMatchCandidate) -> Bool {
let threshold = ContextBiasingConstants.closeSpellingSimilarity
if candidate.similarity >= threshold { return true }
let original = lexicalCore(candidate.originalPhrase)
let forms = [candidate.vocabTerm] + (candidate.matchedAlias.map { [$0] } ?? [])
return forms.contains { stringSimilarity(original, lexicalCore($0)) >= threshold }
}

/// `normalizedLexical` without a trailing possessive: "Quiltor's" → "quiltor", "Jonnes'" → "jonnes".
static func lexicalCore(_ text: String) -> String {
let normalized = normalizedLexical(text)
if normalized.hasSuffix("'s") { return String(normalized.dropLast(2)) }
if normalized.hasSuffix("s'") { return String(normalized.dropLast()) }
return normalized
}

struct RecognizedVocabularySpans {
let ranges: [Range<Int>]
let byCanonical: [String: [Range<Int>]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,15 @@ extension VocabularyRescorer {
// already recognized name on bias alone. Close spellings and aliases
// retain the normal bonus so genuine repetitions can still be corrected.
let configuredBoost = config.adaptiveCbw(baseCbw: cbw, tokenCount: vocabTokensUsed.count)
// A span the TDT decoder heard confidently is most likely what was said: unless it is a close spelling
// of the term (where the listed spelling should win), the term gets no bias against it and must beat
// the span on CTC evidence alone (#967).
let confidentlyHeardOtherWord =
config.confidentWordBiasGate && !Self.isCloseSpelling(candidate)
&& Self.isConfidentlyHeard(candidate, wordTimings: wordTimings)
let adaptiveCbwValue =
searchWindow.createsAdjacentDuplicate && !Self.isTextSupportedRepetition(candidate)
(searchWindow.createsAdjacentDuplicate && !Self.isTextSupportedRepetition(candidate))
|| confidentlyHeardOtherWord
? min(0, configuredBoost) : configuredBoost
let boostedVocabScore = vocabCtcScore + adaptiveCbwValue

Expand Down Expand Up @@ -201,6 +208,18 @@ extension VocabularyRescorer {
)
}

/// Whether the TDT decoder heard every word of the candidate's span with at least
/// `ContextBiasingConstants.confidentWordMinConfidence`.
static func isConfidentlyHeard(_ candidate: CTCMatchCandidate, wordTimings: [WordTiming]) -> Bool {
guard !candidate.spanIndices.isEmpty else { return false }
return candidate.spanIndices.allSatisfy { index in
guard wordTimings.indices.contains(index), let confidence = wordTimings[index].minConfidence else {
return false
}
return confidence >= ContextBiasingConstants.confidentWordMinConfidence
}
}

/// Build a truthful display reason for an available numeric CTC comparison.
static func ctcComparisonReason(
prefix: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ public struct VocabularyRescorer: Sendable {
/// above that this switch has no effect.
public let spotterRescueEnabled: Bool

/// Whether a span every word of which the decoder heard at
/// `ContextBiasingConstants.confidentWordMinConfidence` or more loses the
/// context-bias bonus against a term it isn't a close spelling of (#967).
/// `true` (default) is calibrated on TDT token confidences; the Unified
/// engines' `VocabularyBoostingSession.itnDefaultConfig` turns it off, since
/// their emission probabilities are a different scale and weren't measured.
public let confidentWordBiasGate: Bool

public static let `default` = Config()

public init(
Expand All @@ -89,7 +97,8 @@ public struct VocabularyRescorer: Sendable {
spotterRescueMinSimilarity: Float = ContextBiasingConstants.defaultSpotterRescueMinSimilarity,
spotterRescueMultiWordMinSimilarity: Float = ContextBiasingConstants
.defaultSpotterRescueMultiWordMinSimilarity,
spotterRescueEnabled: Bool = ContextBiasingConstants.defaultSpotterRescueEnabled
spotterRescueEnabled: Bool = ContextBiasingConstants.defaultSpotterRescueEnabled,
confidentWordBiasGate: Bool = true
) {
self.useAdaptiveThresholds = useAdaptiveThresholds
self.referenceTokenCount = referenceTokenCount
Expand All @@ -98,6 +107,7 @@ public struct VocabularyRescorer: Sendable {
self.spotterRescueMinSimilarity = spotterRescueMinSimilarity
self.spotterRescueMultiWordMinSimilarity = spotterRescueMultiWordMinSimilarity
self.spotterRescueEnabled = spotterRescueEnabled
self.confidentWordBiasGate = confidentWordBiasGate
}

// MARK: - Adaptive Threshold Functions
Expand Down Expand Up @@ -413,6 +423,8 @@ public struct VocabularyRescorer: Sendable {
public let startTime: Double
public let endTime: Double
let tokenRange: Range<Int>?
/// Lowest TDT token confidence in the word; nil when the timings carry none.
var minConfidence: Float? = nil
}

/// Build word-level timings from token timings.
Expand All @@ -425,6 +437,7 @@ public struct VocabularyRescorer: Sendable {
var wordTokenStart = 0
var wordTokenEnd = 0
var wordTokensAreContiguous = true
var wordMinConfidence = Float.infinity

for (tokenIndex, timing) in tokenTimings.enumerated() {
let token = timing.token
Expand All @@ -446,7 +459,8 @@ public struct VocabularyRescorer: Sendable {
word: trimmedWord,
startTime: wordStart,
endTime: wordEnd,
tokenRange: wordTokensAreContiguous ? wordTokenStart..<wordTokenEnd : nil
tokenRange: wordTokensAreContiguous ? wordTokenStart..<wordTokenEnd : nil,
minConfidence: wordMinConfidence.isFinite ? wordMinConfidence : nil
))
}
currentWord = ""
Expand All @@ -457,6 +471,7 @@ public struct VocabularyRescorer: Sendable {
wordStart = timing.startTime
wordTokenStart = tokenIndex
wordTokensAreContiguous = true
wordMinConfidence = .infinity
} else {
if tokenIndex != wordTokenEnd {
wordTokensAreContiguous = false
Expand All @@ -465,6 +480,7 @@ public struct VocabularyRescorer: Sendable {
}
wordEnd = timing.endTime
wordTokenEnd = tokenIndex + 1
wordMinConfidence = min(wordMinConfidence, timing.confidence)
}

// Save final word
Expand All @@ -475,7 +491,8 @@ public struct VocabularyRescorer: Sendable {
word: trimmedWord,
startTime: wordStart,
endTime: wordEnd,
tokenRange: wordTokensAreContiguous ? wordTokenStart..<wordTokenEnd : nil
tokenRange: wordTokensAreContiguous ? wordTokenStart..<wordTokenEnd : nil,
minConfidence: wordMinConfidence.isFinite ? wordMinConfidence : nil
))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ public struct VocabularyBoostingSession: Sendable {
/// names the model mangles past the similarity gate.
public static let itnDefaultConfig = VocabularyRescorer.Config(
spotterRescueMinSimilarity: 0.30,
spotterRescueMultiWordMinSimilarity: 0.50
spotterRescueMultiWordMinSimilarity: 0.50,
confidentWordBiasGate: false
)

/// Create a session from a tokenized vocabulary and pre-loaded CTC models.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ final class VocabularySpanPreservationTests: XCTestCase {
.appendingPathComponent("Vocabulary")
let fixtures = try JSONDecoder().decode(
[Fixture].self, from: Data(contentsOf: root.appendingPathComponent("tdt-v3.json")))
for fixture in fixtures where fixture.id != "solanki" {
for fixture in fixtures where fixture.id.hasPrefix("quilter") {
let audio = try AudioConverter().resampleAudioFile(root.appendingPathComponent(fixture.id + ".wav"))
let probabilities = try await spotter.computeLogProbs(for: audio)
let vocabulary = CustomVocabularyContext(terms: [
Expand Down Expand Up @@ -109,6 +109,38 @@ final class VocabularySpanPreservationTests: XCTestCase {
}
}

/// "…form a sort of shell": TDT heard `sort` at full confidence, and a similar listed term
/// must not replace it on bias alone (#967). With the default bias, `sort` became `Sora`.
func testAConfidentlyHeardWordIsNotReplacedBySimilarTerm() async throws {
let directory = CtcModels.defaultCacheDirectory()
let models = try await installedCtcModels()
let spotter = CtcKeywordSpotter(models: models)
let tokenizer = try await CtcTokenizer.load(from: directory)
let root = try XCTUnwrap(Bundle.module.url(forResource: "Fixtures", withExtension: nil))
.appendingPathComponent("Vocabulary")
let fixtures = try JSONDecoder().decode(
[Fixture].self, from: Data(contentsOf: root.appendingPathComponent("tdt-v3.json")))
let fixture = try XCTUnwrap(fixtures.first { $0.id == "sort-of-shell" })
let audio = try AudioConverter().resampleAudioFile(root.appendingPathComponent("sort-of-shell.wav"))
let probabilities = try await spotter.computeLogProbs(for: audio)
// The #967 reporter's visible list; `Sora` is the term that replaced `sort`.
let terms = [
"Qwen", "EMEA", "Tuist", "Codex", "EnviousWispr", "EnviousStaging", "Envious Labs", "PostHog",
"Qualtrics", "Saurabh", "Sora", "Astra",
]
let vocabulary = CustomVocabularyContext(
terms: terms.map { CustomVocabularyTerm(text: $0, ctcTokenIds: tokenizer.encode($0)) })
let size = ContextBiasingConstants.rescorerConfig(forVocabSize: terms.count)
let rescorer = try await VocabularyRescorer.create(
spotter: spotter, vocabulary: vocabulary, config: .default, ctcModelDirectory: directory)
let result = rescorer.ctcTokenRescore(
transcript: fixture.text, tokenTimings: fixture.tokenTimings,
logProbs: probabilities.logProbs, frameDuration: probabilities.frameDuration,
cbw: size.cbw, marginSeconds: 0.5, minSimilarity: size.minSimilarity)
XCTAssertEqual(result.text, fixture.text)
XCTAssertFalse(result.wasModified)
}

func testMisspelledAndSplitNamesAreCorrectedWithoutBorrowingTheirEvidenceTwice() async throws {
let directory = CtcModels.defaultCacheDirectory()
let models = try await installedCtcModels()
Expand All @@ -118,7 +150,7 @@ final class VocabularySpanPreservationTests: XCTestCase {
.appendingPathComponent("Vocabulary")
let fixtures = try JSONDecoder().decode(
[Fixture].self, from: Data(contentsOf: root.appendingPathComponent("tdt-v3.json")))
for fixture in fixtures where fixture.id != "solanki" {
for fixture in fixtures where fixture.id.hasPrefix("quilter") {
let audio = try AudioConverter().resampleAudioFile(root.appendingPathComponent(fixture.id + ".wav"))
let probabilities = try await spotter.computeLogProbs(for: audio)
// Perturb the spelling/segmentation of the recorded TDT tokens only;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,44 @@ final class VocabularySpanProtectionTests: XCTestCase {
XCTAssertFalse(VocabularyRescorer.isCanonicalMatch("Quilters", canonical: "Quilter"))
XCTAssertFalse(VocabularyRescorer.isCanonicalMatch("Lab's", canonical: "Labs"))
}

func testWordTimingsCarryTheirLowestTokenConfidence() {
let words = VocabularyRescorer.buildWordTimings(from: [
TokenTiming(token: " sort", tokenId: 1, startTime: 0, endTime: 0.16, confidence: 0.999),
TokenTiming(token: " Sor", tokenId: 2, startTime: 0.16, endTime: 0.24, confidence: 0.4),
TokenTiming(token: "a", tokenId: 3, startTime: 0.24, endTime: 0.32, confidence: 0.9),
])
XCTAssertEqual(words.map(\.word), ["sort", "Sora"])
XCTAssertEqual(words.map(\.minConfidence), [0.999, 0.4])
}

/// Only a span whose every word TDT heard at `confidentWordMinConfidence` loses the bias (#967).
func testOnlyAFullyConfidentSpanCountsAsConfidentlyHeard() {
let words = VocabularyRescorer.buildWordTimings(from: [
TokenTiming(token: " a", tokenId: 1, startTime: 0, endTime: 0.08, confidence: 1),
TokenTiming(token: " sort", tokenId: 2, startTime: 0.08, endTime: 0.24, confidence: 0.995),
TokenTiming(token: " Sar", tokenId: 3, startTime: 0.24, endTime: 0.32, confidence: 0.39),
TokenTiming(token: "ab", tokenId: 4, startTime: 0.32, endTime: 0.4, confidence: 0.99),
])
XCTAssertTrue(
VocabularyRescorer.isConfidentlyHeard(
candidate("sort", term: "Sora", indices: [1], words: words), wordTimings: words))
XCTAssertFalse(
VocabularyRescorer.isConfidentlyHeard(
candidate("Sarab", term: "Saurabh", indices: [2], words: words), wordTimings: words))
XCTAssertFalse(
VocabularyRescorer.isConfidentlyHeard(
candidate("sort Sarab", term: "Saurabh", indices: [1, 2], words: words), wordTimings: words))
// Timings without confidence never count as confidently heard.
let untimed = [word("sort", frame: 0, frames: 2)]
XCTAssertFalse(
VocabularyRescorer.isConfidentlyHeard(
candidate("sort", term: "Sora", indices: [0], words: untimed), wordTimings: untimed))
}

/// The #967 confident-word gate is calibrated on TDT confidences; the Unified engines' config keeps it off.
func testConfidentWordGateIsOnByDefaultAndOffForTheUnifiedEngines() {
XCTAssertTrue(VocabularyRescorer.Config.default.confidentWordBiasGate)
XCTAssertFalse(VocabularyBoostingSession.itnDefaultConfig.confidentWordBiasGate)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ word must remain unchanged because it is not one complete name span.
superintendent Chandra Shekhar Solanki said the accused appeared in court
with covered faces.” Keeps the actual TDT `Solansky` → `Solanki` correction
working with the short-term bias taper enabled.
- `sort-of-shell.wav`: FLEURS `en_us:1803:11987168412167267165.wav`, “The
moisture on your hands will react with the outer layers, which will feel funny
and form a sort of shell.” TDT hears `sort` at full confidence; a similar listed
term (`Sora`) must not replace it on bias alone (#967).

## Attribution and license

Expand All @@ -53,3 +57,4 @@ SHA-256 integrity hashes:
| quilter.wav | `799f78ed4beb4de7ceae3a809262d4ce242394342ccd1d58cef7d49dbc2def46` |
| quilter-possessive.wav | `473b1bb573e8f7cdcd639c6e318036236190107b66bfe6892286121a92d464c1` |
| solanki.wav | `509bc6279b6549a1ed01a712950f0593e43380204f77f39a4fdd11bd216c68c0` |
| sort-of-shell.wav | `0dfd53aba5bcaa70a09992b937b189f10f273d3b5f3667cb5afc1eeab2b7aa5c` |
Binary file not shown.
Loading
Loading