Skip to content

Commit ed7ebe4

Browse files
committed
Fix forced_decoder_ids parsing for HuggingFace generation config
1 parent 57a0a63 commit ed7ebe4

3 files changed

Lines changed: 103 additions & 1 deletion

File tree

Package.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,11 @@ let package = Package(
266266
.linkedLibrary("c++")
267267
]
268268
),
269+
.testTarget(
270+
name: "SpeechTests",
271+
dependencies: ["CoreAISpeech"],
272+
path: "swift/Tests/SpeechTests"
273+
),
269274
],
270275
swiftLanguageModes: [.v6],
271276
cxxLanguageStandard: .cxx17

swift/Sources/CoreAISpeech/SpeechBundle.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,20 @@ public struct GenerationConfig: Sendable {
100100
init(from url: URL) throws {
101101
let data = try Data(contentsOf: url)
102102
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
103-
forcedPrefix = (json["forced_decoder_ids"] as? [Int]).map { $0.map { Int32($0) } } ?? Self.whisper.forcedPrefix
103+
if let rawPairs = json["forced_decoder_ids"] as? [[Any]] {
104+
// HF format: [[position, token_id], ...] where token_id can be null
105+
var tokens: [(pos: Int, id: Int32)] = []
106+
for pair in rawPairs {
107+
guard pair.count >= 2,
108+
let pos = pair[0] as? Int,
109+
let id = pair[1] as? Int
110+
else { continue }
111+
tokens.append((pos: pos, id: Int32(id)))
112+
}
113+
forcedPrefix = tokens.sorted { $0.pos < $1.pos }.map(\.id)
114+
} else {
115+
forcedPrefix = Self.whisper.forcedPrefix
116+
}
104117
eotToken = (json["eos_token_id"] as? Int).map { Int32($0) } ?? Self.whisper.eotToken
105118
maxDecodeSteps = (json["max_new_tokens"] as? Int) ?? Self.whisper.maxDecodeSteps
106119
tokenizerName = json["tokenizer_name"] as? String ?? Self.whisper.tokenizerName
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright 2026 Apple Inc.
2+
//
3+
// Use of this source code is governed by a BSD-3-clause license that can
4+
// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
5+
6+
import Foundation
7+
import Testing
8+
9+
@testable import CoreAISpeech
10+
11+
@Suite("GenerationConfig")
12+
struct GenerationConfigTests {
13+
@Test("Parses forced_decoder_ids from HuggingFace format")
14+
func parseForcedDecoderIds() throws {
15+
let json: [String: Any] = [
16+
"forced_decoder_ids": [[1, 50258], [2, 50259], [3, 50360], [4, 50364]],
17+
"eos_token_id": 50257,
18+
"max_new_tokens": 100,
19+
]
20+
let data = try JSONSerialization.data(withJSONObject: json)
21+
let url = FileManager.default.temporaryDirectory.appendingPathComponent("gen_config.json")
22+
try data.write(to: url)
23+
defer { try? FileManager.default.removeItem(at: url) }
24+
25+
let config = try GenerationConfig(from: url)
26+
#expect(config.forcedPrefix == [50258, 50259, 50360, 50364])
27+
#expect(config.eotToken == 50257)
28+
#expect(config.maxDecodeSteps == 100)
29+
}
30+
31+
@Test("Falls back to Whisper defaults when forced_decoder_ids is missing")
32+
func fallbackOnMissingField() throws {
33+
let json: [String: Any] = ["eos_token_id": 50257]
34+
let data = try JSONSerialization.data(withJSONObject: json)
35+
let url = FileManager.default.temporaryDirectory.appendingPathComponent("gen_config2.json")
36+
try data.write(to: url)
37+
defer { try? FileManager.default.removeItem(at: url) }
38+
39+
let config = try GenerationConfig(from: url)
40+
#expect(config.forcedPrefix == GenerationConfig.whisper.forcedPrefix)
41+
}
42+
43+
@Test("Falls back to Whisper defaults when forced_decoder_ids has wrong format")
44+
func fallbackOnWrongFormat() throws {
45+
let json: [String: Any] = [
46+
"forced_decoder_ids": [50258, 50259, 50360], // flat array, not pairs
47+
]
48+
let data = try JSONSerialization.data(withJSONObject: json)
49+
let url = FileManager.default.temporaryDirectory.appendingPathComponent("gen_config3.json")
50+
try data.write(to: url)
51+
defer { try? FileManager.default.removeItem(at: url) }
52+
53+
let config = try GenerationConfig(from: url)
54+
#expect(config.forcedPrefix == GenerationConfig.whisper.forcedPrefix)
55+
}
56+
57+
@Test("Skips null token IDs in forced_decoder_ids pairs")
58+
func skipsNullTokenIds() throws {
59+
let json: [String: Any] = [
60+
"forced_decoder_ids": [[1, NSNull()], [2, 50360], [3, 50364]],
61+
]
62+
let data = try JSONSerialization.data(withJSONObject: json)
63+
let url = FileManager.default.temporaryDirectory.appendingPathComponent("gen_config4.json")
64+
try data.write(to: url)
65+
defer { try? FileManager.default.removeItem(at: url) }
66+
67+
let config = try GenerationConfig(from: url)
68+
#expect(config.forcedPrefix == [50360, 50364])
69+
}
70+
71+
@Test("Handles out-of-order positions")
72+
func outOfOrderPositions() throws {
73+
let json: [String: Any] = [
74+
"forced_decoder_ids": [[3, 50360], [1, 50258], [2, 50259]],
75+
]
76+
let data = try JSONSerialization.data(withJSONObject: json)
77+
let url = FileManager.default.temporaryDirectory.appendingPathComponent("gen_config5.json")
78+
try data.write(to: url)
79+
defer { try? FileManager.default.removeItem(at: url) }
80+
81+
let config = try GenerationConfig(from: url)
82+
#expect(config.forcedPrefix == [50258, 50259, 50360])
83+
}
84+
}

0 commit comments

Comments
 (0)