Skip to content

Commit 86b3a37

Browse files
committed
Add JSON-mode inference logits constraints
1 parent 9be85b8 commit 86b3a37

9 files changed

Lines changed: 626 additions & 2 deletions

File tree

cppmega_mlx/inference/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
rollback_contiguous_kv_cache,
1212
trim_contiguous_kv_cache,
1313
)
14+
from cppmega_mlx.inference.constraints import (
15+
JsonConstrainedLogitsProcessor,
16+
JsonTokenIds,
17+
LogitsProcessor,
18+
)
1419
from cppmega_mlx.inference.infilling import build_fim_prompt_ids
1520
from cppmega_mlx.inference.generation import (
1621
GenerationChunk,
@@ -82,6 +87,9 @@
8287
"InferenceSideChannelBuilder",
8388
"InferenceSideChannelCacheComponents",
8489
"InferenceSideChannelResult",
90+
"JsonConstrainedLogitsProcessor",
91+
"JsonTokenIds",
92+
"LogitsProcessor",
8593
"GoCodeMetadataAdapter",
8694
"PAGED_ATTENTION_NOT_INTEGRATED_MESSAGE",
8795
"PagedKVBlockManager",
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
"""Token-id logits processors for constrained local inference."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Sequence
6+
from dataclasses import dataclass
7+
from typing import Protocol
8+
9+
import mlx.core as mx
10+
import numpy as np
11+
12+
13+
TokenIdSet = int | Sequence[int]
14+
15+
16+
class LogitsProcessor(Protocol):
17+
"""Callable hook that can mask or transform next-token logits."""
18+
19+
def __call__(self, logits: mx.array, tokens: mx.array) -> mx.array:
20+
"""Return processed ``(batch, vocab)`` logits for the current prefix."""
21+
22+
23+
@dataclass(frozen=True)
24+
class JsonTokenIds:
25+
"""Token-id categories used by the scoped JSON constrained decoder.
26+
27+
The processor works in token coordinates. The caller owns tokenizer-specific
28+
mapping from raw text pieces to these categories; this class deliberately
29+
does not decode text or claim JSON Schema support.
30+
"""
31+
32+
object_start: int
33+
object_end: int
34+
array_start: int
35+
array_end: int
36+
colon: int
37+
comma: int
38+
string: TokenIdSet
39+
number: TokenIdSet
40+
true_literal: TokenIdSet
41+
false_literal: TokenIdSet
42+
null_literal: TokenIdSet
43+
whitespace: Sequence[int] = ()
44+
eos_token_id: int | None = None
45+
46+
def __post_init__(self) -> None:
47+
for name in (
48+
"object_start",
49+
"object_end",
50+
"array_start",
51+
"array_end",
52+
"colon",
53+
"comma",
54+
):
55+
_validate_single_token_id(getattr(self, name), name)
56+
for name in (
57+
"string",
58+
"number",
59+
"true_literal",
60+
"false_literal",
61+
"null_literal",
62+
):
63+
_normalize_token_ids(getattr(self, name), name)
64+
_normalize_token_ids(tuple(self.whitespace), "whitespace", allow_empty=True)
65+
if self.eos_token_id is not None:
66+
_validate_single_token_id(self.eos_token_id, "eos_token_id")
67+
68+
69+
class JsonConstrainedLogitsProcessor:
70+
"""Mask logits to token IDs that keep a token-category JSON prefix valid."""
71+
72+
def __init__(self, token_ids: JsonTokenIds, *, start_position: int = 0) -> None:
73+
if start_position < 0:
74+
raise ValueError("start_position must be non-negative")
75+
self.token_ids = token_ids
76+
self.start_position = start_position
77+
self._whitespace = _normalize_token_ids(
78+
tuple(token_ids.whitespace),
79+
"whitespace",
80+
allow_empty=True,
81+
)
82+
self._string = _normalize_token_ids(token_ids.string, "string")
83+
self._number = _normalize_token_ids(token_ids.number, "number")
84+
self._true = _normalize_token_ids(token_ids.true_literal, "true_literal")
85+
self._false = _normalize_token_ids(token_ids.false_literal, "false_literal")
86+
self._null = _normalize_token_ids(token_ids.null_literal, "null_literal")
87+
self._scalar_values = self._string + self._number + self._true + self._false + self._null
88+
self._value_starts = (
89+
token_ids.object_start,
90+
token_ids.array_start,
91+
*self._scalar_values,
92+
)
93+
self._all_configured_ids = tuple(
94+
dict.fromkeys(
95+
(
96+
token_ids.object_start,
97+
token_ids.object_end,
98+
token_ids.array_start,
99+
token_ids.array_end,
100+
token_ids.colon,
101+
token_ids.comma,
102+
*self._scalar_values,
103+
*self._whitespace,
104+
*((token_ids.eos_token_id,) if token_ids.eos_token_id is not None else ()),
105+
)
106+
)
107+
)
108+
109+
def allowed_token_ids(self, tokens: mx.array) -> tuple[int, ...]:
110+
"""Return valid next token IDs for one batch row prefix."""
111+
112+
rows = _tokens_to_rows(tokens, start_position=self.start_position)
113+
if len(rows) != 1:
114+
raise ValueError("allowed_token_ids expects a single batch row")
115+
return self._allowed_for_prefix(rows[0])
116+
117+
def __call__(self, logits: mx.array, tokens: mx.array) -> mx.array:
118+
if len(logits.shape) != 2:
119+
raise ValueError("logits must have shape (batch, vocab)")
120+
rows = _tokens_to_rows(tokens, start_position=self.start_position)
121+
if len(rows) != int(logits.shape[0]):
122+
raise ValueError("tokens batch size must match logits batch size")
123+
124+
vocab_size = int(logits.shape[1])
125+
for token_id in self._all_configured_ids:
126+
if token_id >= vocab_size:
127+
raise ValueError("JSON token id is outside logits vocabulary")
128+
129+
vocab_ids = mx.arange(vocab_size, dtype=mx.int32)
130+
row_masks: list[mx.array] = []
131+
for row in rows:
132+
allowed = self._allowed_for_prefix(row)
133+
if not allowed:
134+
raise ValueError("JSON constraint produced no allowed token ids")
135+
allowed_ids = mx.array(allowed, dtype=mx.int32)
136+
row_masks.append(mx.any(vocab_ids[:, None] == allowed_ids[None, :], axis=1))
137+
138+
mask = mx.stack(row_masks, axis=0)
139+
neg_inf = mx.full(logits.shape, float("-inf"), dtype=logits.dtype)
140+
return mx.where(mask, logits, neg_inf)
141+
142+
def _allowed_for_prefix(self, row: Sequence[int]) -> tuple[int, ...]:
143+
stack, root_complete = self._parse_prefix(row)
144+
allowed: list[int] = list(self._whitespace)
145+
if not stack:
146+
if root_complete:
147+
if self.token_ids.eos_token_id is not None:
148+
allowed.append(self.token_ids.eos_token_id)
149+
else:
150+
allowed.extend(self._value_starts)
151+
return _unique(allowed)
152+
153+
top = stack[-1]
154+
if top == "object_key_or_end":
155+
allowed.extend((self.token_ids.object_end, *self._string))
156+
elif top == "object_colon":
157+
allowed.append(self.token_ids.colon)
158+
elif top == "object_value":
159+
allowed.extend(self._value_starts)
160+
elif top == "object_comma_or_end":
161+
allowed.extend((self.token_ids.comma, self.token_ids.object_end))
162+
elif top == "array_value_or_end":
163+
allowed.extend((self.token_ids.array_end, *self._value_starts))
164+
elif top == "array_comma_or_end":
165+
allowed.extend((self.token_ids.comma, self.token_ids.array_end))
166+
else:
167+
raise ValueError(f"invalid JSON parser state: {top}")
168+
return _unique(allowed)
169+
170+
def _parse_prefix(self, row: Sequence[int]) -> tuple[list[str], bool]:
171+
prefix = tuple(int(token_id) for token_id in row)
172+
stack: list[str] = []
173+
root_complete = False
174+
175+
def mark_value_complete() -> None:
176+
nonlocal root_complete
177+
if not stack:
178+
if root_complete:
179+
raise ValueError("invalid JSON prefix: multiple root values")
180+
root_complete = True
181+
return
182+
top = stack[-1]
183+
if top == "object_value":
184+
stack[-1] = "object_comma_or_end"
185+
elif top == "array_value_or_end":
186+
stack[-1] = "array_comma_or_end"
187+
else:
188+
raise ValueError("invalid JSON prefix: value in unexpected position")
189+
190+
def expect_value() -> bool:
191+
return (
192+
not stack
193+
and not root_complete
194+
or bool(stack)
195+
and stack[-1] in {"object_value", "array_value_or_end"}
196+
)
197+
198+
for token_id in prefix:
199+
if token_id in self._whitespace:
200+
continue
201+
if token_id == self.token_ids.eos_token_id:
202+
if stack or not root_complete:
203+
raise ValueError("invalid JSON prefix: EOS before complete root")
204+
root_complete = True
205+
continue
206+
if not stack and root_complete:
207+
raise ValueError("invalid JSON prefix: token after complete root")
208+
209+
if token_id == self.token_ids.object_start:
210+
if not expect_value():
211+
raise ValueError("invalid JSON prefix: object start in unexpected position")
212+
stack.append("object_key_or_end")
213+
elif token_id == self.token_ids.array_start:
214+
if not expect_value():
215+
raise ValueError("invalid JSON prefix: array start in unexpected position")
216+
stack.append("array_value_or_end")
217+
elif token_id in self._scalar_values:
218+
if stack and stack[-1] == "object_key_or_end" and token_id in self._string:
219+
stack[-1] = "object_colon"
220+
else:
221+
if not expect_value():
222+
raise ValueError("invalid JSON prefix: scalar in unexpected position")
223+
mark_value_complete()
224+
elif token_id == self.token_ids.colon:
225+
if not stack or stack[-1] != "object_colon":
226+
raise ValueError("invalid JSON prefix: colon in unexpected position")
227+
stack[-1] = "object_value"
228+
elif token_id == self.token_ids.comma:
229+
if not stack or stack[-1] not in {"object_comma_or_end", "array_comma_or_end"}:
230+
raise ValueError("invalid JSON prefix: comma in unexpected position")
231+
stack[-1] = (
232+
"object_key_or_end"
233+
if stack[-1] == "object_comma_or_end"
234+
else "array_value_or_end"
235+
)
236+
elif token_id == self.token_ids.object_end:
237+
if not stack or stack[-1] not in {"object_key_or_end", "object_comma_or_end"}:
238+
raise ValueError("invalid JSON prefix: object end in unexpected position")
239+
stack.pop()
240+
mark_value_complete()
241+
elif token_id == self.token_ids.array_end:
242+
if not stack or stack[-1] not in {"array_value_or_end", "array_comma_or_end"}:
243+
raise ValueError("invalid JSON prefix: array end in unexpected position")
244+
stack.pop()
245+
mark_value_complete()
246+
else:
247+
raise ValueError(f"invalid JSON prefix: uncategorized token id {token_id}")
248+
249+
return stack, root_complete
250+
251+
252+
def _validate_single_token_id(value: object, name: str) -> None:
253+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
254+
raise ValueError(f"{name} must be a non-negative integer token id")
255+
256+
257+
def _normalize_token_ids(
258+
value: TokenIdSet,
259+
name: str,
260+
*,
261+
allow_empty: bool = False,
262+
) -> tuple[int, ...]:
263+
if isinstance(value, bool):
264+
raise ValueError(f"{name} must be a non-negative integer token id")
265+
if isinstance(value, int):
266+
tokens = (value,)
267+
else:
268+
tokens = tuple(int(token_id) for token_id in value)
269+
if not tokens and not allow_empty:
270+
raise ValueError(f"{name} must contain at least one token id")
271+
for token_id in tokens:
272+
_validate_single_token_id(token_id, name)
273+
return tokens
274+
275+
276+
def _tokens_to_rows(
277+
tokens: mx.array,
278+
*,
279+
start_position: int = 0,
280+
) -> tuple[tuple[int, ...], ...]:
281+
if len(tokens.shape) != 2:
282+
raise ValueError("tokens must have shape (batch, sequence)")
283+
mx.eval(tokens)
284+
values = np.array(tokens[:, start_position:])
285+
return tuple(tuple(int(token_id) for token_id in row) for row in values)
286+
287+
288+
def _unique(token_ids: Sequence[int]) -> tuple[int, ...]:
289+
return tuple(dict.fromkeys(token_ids))

0 commit comments

Comments
 (0)