Skip to content

Commit c94798c

Browse files
stikvessukru tikves
authored andcommitted
Extend ConstrainedGenerationSession with rollback, jump-forward, and direct bitmask fill
- rollback(_:) — unwind grammar state by N tokens (budget: 64) - findJumpForwardString() — peek at deterministic continuations - fillBitmask(into:) → BitmaskResult — write bitmask directly into a caller-provided pointer (e.g. GPU-visible MTLBuffer), avoiding array allocation per token - BitmaskResult enum: .terminated, .unconstrained, .constrained - C bridge additions: XGrammarRollback, XGrammarFindJumpForwardString, XGrammarFillNextTokenBitmask Part 1 of 4 for GPU-based constrained sampling (#114).
1 parent aa3bbf6 commit c94798c

5 files changed

Lines changed: 144 additions & 1 deletion

File tree

swift/Sources/CoreAILanguageModels/GuidedGeneration/ConstrainedGenerationSession.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public struct ConstrainedGenerationSession: ~Copyable {
9393
self.tokenizerInfo = tokenizerInfo
9494
self.compiler = GrammarCompiler(tokenizerInfo: tokenizerInfo)
9595
self.compiledGrammar = try compiler.compileJSONSchema(jsonSchema)
96-
self.matcher = GrammarMatcher(compiledGrammar: compiledGrammar)
96+
self.matcher = GrammarMatcher(compiledGrammar: compiledGrammar, maxRollbackTokens: 64)
9797
self.vocabularySize = tokenizerInfo.vocabularySize
9898
self.bitmaskSize = (vocabularySize + 31) / 32
9999
self.bitmaskBuffer = Array(repeating: 0, count: bitmaskSize)
@@ -193,6 +193,49 @@ public struct ConstrainedGenerationSession: ~Copyable {
193193
matcher.reset()
194194
allTokensBlocked = false
195195
}
196+
197+
/// Rollback the grammar state by N tokens. Returns false if rollback failed
198+
/// (e.g., exceeds maxRollbackTokens budget).
199+
@discardableResult
200+
public mutating func rollback(_ numTokens: Int = 1) -> Bool {
201+
matcher.rollback(numTokens)
202+
}
203+
204+
/// Find the longest deterministic string from the current grammar state.
205+
/// Does not change the matcher state. Returns nil if no jump-forward is possible.
206+
public func findJumpForwardString() -> String? {
207+
matcher.findJumpForwardString()
208+
}
209+
210+
/// Result of filling a bitmask for the next token.
211+
public enum BitmaskResult: Equatable {
212+
/// Grammar is terminated or all tokens are blocked — generation should stop.
213+
case terminated
214+
/// All tokens are allowed — no mask needed, generate unconstrained.
215+
case unconstrained
216+
/// Bitmask was written — apply it to constrain sampling.
217+
case constrained
218+
}
219+
220+
/// Fill the bitmask directly into a caller-provided buffer (e.g., a GPU-visible MTLBuffer).
221+
///
222+
/// The caller must ensure the pointer has room for at least `(vocabularySize + 31) / 32`
223+
/// Int32 words.
224+
public mutating func fillBitmask(into pointer: UnsafeMutablePointer<Int32>) -> BitmaskResult {
225+
if isTerminated { return .terminated }
226+
227+
let needsApplication = matcher.fillNextTokenBitmask(pointer)
228+
if !needsApplication {
229+
// xgrammar signals all tokens are allowed — no mask needed
230+
return .unconstrained
231+
}
232+
// Check for all-zeros (no tokens allowed — grammar done)
233+
for i in 0..<bitmaskSize {
234+
if pointer[i] != 0 { return .constrained }
235+
}
236+
allTokensBlocked = true
237+
return .terminated
238+
}
196239
}
197240

198241
// MARK: - Float16 Masking

swift/Sources/CoreAILanguageModels/GuidedGeneration/XGrammarWrapper.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,29 @@ public final class GrammarMatcher {
136136
return xgrammar_matcher_is_terminated(handle)
137137
}
138138

139+
public var isCompleted: Bool {
140+
return xgrammar_matcher_is_completed(handle)
141+
}
142+
139143
public func reset() {
140144
xgrammar_matcher_reset(handle)
141145
}
146+
147+
@discardableResult
148+
public func rollback(_ numTokens: Int = 1) -> Bool {
149+
xgrammar_matcher_rollback(handle, Int32(numTokens))
150+
}
151+
152+
/// Returns the longest deterministic string from the current grammar state,
153+
/// or nil if no jump-forward is possible. Does not change matcher state.
154+
public func findJumpForwardString() -> String? {
155+
guard let cStr = xgrammar_matcher_find_jump_forward_string(handle) else {
156+
return nil
157+
}
158+
let result = String(cString: cStr)
159+
free(UnsafeMutablePointer(mutating: cStr))
160+
return result.isEmpty ? nil : result
161+
}
142162
}
143163

144164
// MARK: - Errors

swift/Sources/lib/CXGrammar/include/xgrammar_c_bridge.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ bool xgrammar_matcher_is_terminated(const XGrammarMatcher* matcher);
101101
// Reset matcher
102102
void xgrammar_matcher_reset(XGrammarMatcher* matcher);
103103

104+
// Rollback N tokens (restores grammar state to N tokens ago)
105+
bool xgrammar_matcher_rollback(XGrammarMatcher* matcher, int num_tokens);
106+
107+
// Find the longest deterministic string from the current grammar state.
108+
// Returns a malloc'd C string (caller must free), or NULL if no jump-forward is possible.
109+
const char* xgrammar_matcher_find_jump_forward_string(XGrammarMatcher* matcher);
110+
111+
// Check if the grammar's root rule has been fully matched (without requiring stop token)
112+
bool xgrammar_matcher_is_completed(const XGrammarMatcher* matcher);
113+
104114
// Free grammar matcher
105115
void xgrammar_matcher_free(XGrammarMatcher* matcher);
106116

swift/Sources/lib/CXGrammar/xgrammar_c_bridge.cpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,39 @@ void xgrammar_matcher_reset(XGrammarMatcher* matcher) {
222222
} catch (...) {}
223223
}
224224

225+
bool xgrammar_matcher_rollback(XGrammarMatcher* matcher, int num_tokens) {
226+
try {
227+
if (matcher) {
228+
matcher->cpp_obj.Rollback(num_tokens);
229+
return true;
230+
}
231+
} catch (...) {}
232+
return false;
233+
}
234+
235+
const char* xgrammar_matcher_find_jump_forward_string(XGrammarMatcher* matcher) {
236+
try {
237+
if (!matcher) return nullptr;
238+
std::string result = matcher->cpp_obj.FindJumpForwardString();
239+
if (result.empty()) return nullptr;
240+
char* buf = static_cast<char*>(malloc(result.size() + 1));
241+
if (!buf) return nullptr;
242+
memcpy(buf, result.c_str(), result.size() + 1);
243+
return buf;
244+
} catch (...) {
245+
return nullptr;
246+
}
247+
}
248+
249+
bool xgrammar_matcher_is_completed(const XGrammarMatcher* matcher) {
250+
try {
251+
if (!matcher) return false;
252+
return matcher->cpp_obj.IsCompleted();
253+
} catch (...) {
254+
return false;
255+
}
256+
}
257+
225258
void xgrammar_matcher_free(XGrammarMatcher* matcher) {
226259
delete matcher;
227260
}

swift/Tests/GuidedGenerationTests/ConstrainedGenerationSessionTests.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,43 @@ struct ConstrainedGenerationSessionTests {
8484
#expect(allowedTokenIDs(session: &session).isEmpty)
8585
}
8686

87+
@Test func fillBitmaskIntoPointerMatchesNextTokenBitmask() throws {
88+
var session = try createTestSession()
89+
90+
// Get bitmask via the array-returning method
91+
let arrayBitmask = session.nextTokenBitmask()
92+
#expect(arrayBitmask != nil)
93+
94+
// Reset and get bitmask via the pointer method
95+
session.reset()
96+
let bitmaskSize = (session.vocabularySize + 31) / 32
97+
var pointerBitmask = [Int32](repeating: 0, count: bitmaskSize)
98+
let result = pointerBitmask.withUnsafeMutableBufferPointer { buf in
99+
session.fillBitmask(into: buf.baseAddress!)
100+
}
101+
#expect(result == .constrained)
102+
103+
// They should be identical
104+
#expect(arrayBitmask!.count == pointerBitmask.count)
105+
for i in 0..<arrayBitmask!.count {
106+
#expect(
107+
arrayBitmask![i] == pointerBitmask[i],
108+
"Mismatch at word \(i): array=\(arrayBitmask![i]) pointer=\(pointerBitmask[i])")
109+
}
110+
}
111+
112+
@Test func fillBitmaskReturnsTerminatedWhenDone() throws {
113+
var session = try createTestSession()
114+
driveToCompletion(session: &session)
115+
116+
let bitmaskSize = (session.vocabularySize + 31) / 32
117+
var buffer = [Int32](repeating: 0, count: bitmaskSize)
118+
let result = buffer.withUnsafeMutableBufferPointer { buf in
119+
session.fillBitmask(into: buf.baseAddress!)
120+
}
121+
#expect(result == .terminated)
122+
}
123+
87124
// MARK: - Token Acceptance Tests
88125

89126
@Test func acceptToken() throws {

0 commit comments

Comments
 (0)