Skip to content

Commit 31ad737

Browse files
committed
feat: implement C++ tool-use prompt templates and loss masking with updated special token contract
1 parent 13f9353 commit 31ad737

17 files changed

Lines changed: 684 additions & 19 deletions

cppmega_mlx/data/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from cppmega_mlx.data.tokenizer_contract import (
6464
REQUIRED_SPECIAL_TOKEN_IDS,
6565
SpecialTokenMapping,
66+
TOOL_USE_SPECIAL_TOKEN_IDS,
6667
validate_required_special_token_ids,
6768
)
6869
from cppmega_mlx.data.token_dataset import (
@@ -103,6 +104,7 @@
103104
"TokenDatasetMetadata",
104105
"TorchDataLoaderBridgeConfig",
105106
"TorchDataLoaderBridgeError",
107+
"TOOL_USE_SPECIAL_TOKEN_IDS",
106108
"TokenNpzDataset",
107109
"TokenParquetDataset",
108110
"apply_fim_permutation",

cppmega_mlx/data/tokenizer_contract.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@
1616
"NL": 47,
1717
}
1818

19+
TOOL_USE_SPECIAL_TOKEN_IDS: dict[str, int] = {
20+
"BOS": 2,
21+
"EOS": 3,
22+
"CODE_START": 7,
23+
"CODE_END": 8,
24+
"THINK_START": 9,
25+
"THINK_END": 10,
26+
"QUERY_TOOL": 11,
27+
"TOOL_RESULT": 19,
28+
}
29+
1930
SpecialTokenMapping = Mapping[int, str] | Mapping[str, int]
2031

2132

@@ -28,15 +39,6 @@ def validate_required_special_token_ids(mapping: SpecialTokenMapping) -> None:
2839
"""
2940

3041
token_to_id = _normalize_special_token_mapping(mapping)
31-
seen_ids: dict[int, str] = {}
32-
for token, token_id in token_to_id.items():
33-
existing = seen_ids.setdefault(token_id, token)
34-
if existing != token:
35-
raise ValueError(
36-
f"special token id collision: id {token_id} maps to both "
37-
f"{existing!r} and {token!r}"
38-
)
39-
4042
for token, expected_id in REQUIRED_SPECIAL_TOKEN_IDS.items():
4143
if token not in token_to_id:
4244
raise ValueError(f"missing required special token {token!r}")
@@ -46,6 +48,15 @@ def validate_required_special_token_ids(mapping: SpecialTokenMapping) -> None:
4648
f"special token {token!r} must use id {expected_id}, got {actual_id}"
4749
)
4850

51+
seen_ids: dict[int, str] = {}
52+
for token, token_id in token_to_id.items():
53+
existing = seen_ids.setdefault(token_id, token)
54+
if existing != token:
55+
raise ValueError(
56+
f"special token id collision: id {token_id} maps to both "
57+
f"{existing!r} and {token!r}"
58+
)
59+
4960

5061
def _normalize_special_token_mapping(mapping: SpecialTokenMapping) -> dict[str, int]:
5162
if not mapping:
@@ -87,5 +98,6 @@ def _is_int_key(value: object) -> bool:
8798
__all__ = [
8899
"REQUIRED_SPECIAL_TOKEN_IDS",
89100
"SpecialTokenMapping",
101+
"TOOL_USE_SPECIAL_TOKEN_IDS",
90102
"validate_required_special_token_ids",
91103
]

cppmega_mlx/inference/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@
7272
typical_acceptance,
7373
typical_acceptance_batch,
7474
)
75+
from cppmega_mlx.inference.tool_use import (
76+
ToolUseBlock,
77+
ToolUseSpecialTokenIds,
78+
compute_tool_use_loss_mask,
79+
encode_tool_use_template,
80+
render_tool_use_template,
81+
)
7582

7683
__all__ = [
7784
"ContiguousKVCache",
@@ -100,13 +107,17 @@
100107
"SchedulerOutput",
101108
"SequenceRequest",
102109
"TokenMetadata",
110+
"ToolUseBlock",
111+
"ToolUseSpecialTokenIds",
103112
"UnavailableCodeMetadataAdapter",
104113
"build_prompt_cache",
105114
"build_fim_prompt_ids",
106115
"build_paged_block_table",
107116
"builtin_code_metadata_adapters",
108117
"clone_contiguous_kv_cache",
118+
"compute_tool_use_loss_mask",
109119
"create_local_generation_app",
120+
"encode_tool_use_template",
110121
"generate_tokens",
111122
"generate_tokens_mtp_self_speculative",
112123
"generate_tokens_speculative",
@@ -122,6 +133,7 @@
122133
"quantize_kv_cache",
123134
"quantize_linear_for_inference",
124135
"require_model_integrated_paged_attention",
136+
"render_tool_use_template",
125137
"rollback_contiguous_kv_cache",
126138
"sample_next_token",
127139
"should_start_kv_quantization",

cppmega_mlx/inference/tool_use.py

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
"""Cppmega C++ tool-use prompt templates and loss masks."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping, Sequence
6+
from dataclasses import dataclass
7+
from typing import Any
8+
9+
from cppmega_mlx.data.tokenizer_contract import TOOL_USE_SPECIAL_TOKEN_IDS
10+
11+
12+
TOOL_USE_SPECIAL_TOKEN_TEXT: dict[str, str] = {
13+
"bos": "<BOS>",
14+
"eos": "<EOS>",
15+
"code_start": "<CODE_START>",
16+
"code_end": "<CODE_END>",
17+
"think_start": "<THINK_START>",
18+
"think_end": "<THINK_END>",
19+
"query_tool": "<QUERY_TOOL>",
20+
"tool_result": "<TOOL_RESULT>",
21+
}
22+
23+
_RESERVED_TOOL_USE_TOKENS = frozenset(TOOL_USE_SPECIAL_TOKEN_TEXT.values())
24+
25+
26+
@dataclass(frozen=True)
27+
class ToolUseSpecialTokenIds:
28+
"""Stable IDs for the deployed cppmega tool-use tokenizer protocol."""
29+
30+
bos: int = TOOL_USE_SPECIAL_TOKEN_IDS["BOS"]
31+
eos: int = TOOL_USE_SPECIAL_TOKEN_IDS["EOS"]
32+
code_start: int = TOOL_USE_SPECIAL_TOKEN_IDS["CODE_START"]
33+
code_end: int = TOOL_USE_SPECIAL_TOKEN_IDS["CODE_END"]
34+
think_start: int = TOOL_USE_SPECIAL_TOKEN_IDS["THINK_START"]
35+
think_end: int = TOOL_USE_SPECIAL_TOKEN_IDS["THINK_END"]
36+
query_tool: int = TOOL_USE_SPECIAL_TOKEN_IDS["QUERY_TOOL"]
37+
tool_result: int = TOOL_USE_SPECIAL_TOKEN_IDS["TOOL_RESULT"]
38+
39+
@classmethod
40+
def from_mapping(cls, mapping: Mapping[str, int]) -> "ToolUseSpecialTokenIds":
41+
"""Build IDs from a mapping using either canonical or lowercase keys."""
42+
43+
return cls(
44+
bos=_lookup_id(mapping, "BOS", "bos"),
45+
eos=_lookup_id(mapping, "EOS", "EOT", "eos"),
46+
code_start=_lookup_id(mapping, "CODE_START", "code_start"),
47+
code_end=_lookup_id(mapping, "CODE_END", "code_end"),
48+
think_start=_lookup_id(mapping, "THINK_START", "think_start"),
49+
think_end=_lookup_id(mapping, "THINK_END", "think_end"),
50+
query_tool=_lookup_id(mapping, "QUERY_TOOL", "query_tool"),
51+
tool_result=_lookup_id(mapping, "TOOL_RESULT", "tool_result"),
52+
)
53+
54+
def as_mapping(self) -> dict[str, int]:
55+
return {
56+
"bos": self.bos,
57+
"eos": self.eos,
58+
"code_start": self.code_start,
59+
"code_end": self.code_end,
60+
"think_start": self.think_start,
61+
"think_end": self.think_end,
62+
"query_tool": self.query_tool,
63+
"tool_result": self.tool_result,
64+
}
65+
66+
67+
@dataclass(frozen=True)
68+
class ToolUseBlock:
69+
"""One ordered cppmega tool-use turn.
70+
71+
Fields are rendered in protocol order: model thinking, tool query, injected
72+
tool result, then final or intermediate code.
73+
"""
74+
75+
think: str | None = None
76+
query_tool: str | None = None
77+
tool_result: str | None = None
78+
code: str | None = None
79+
80+
81+
def render_tool_use_template(
82+
instruction: str,
83+
blocks: Sequence[ToolUseBlock] = (),
84+
*,
85+
include_bos: bool = True,
86+
include_eos: bool = True,
87+
allow_special_tokens: bool = False,
88+
) -> str:
89+
"""Render cppmega's C++ tool-call protocol as plain text."""
90+
91+
if not allow_special_tokens:
92+
_reject_reserved_tokens("instruction", instruction)
93+
for block_index, block in enumerate(blocks):
94+
_reject_block_reserved_tokens(block_index, block)
95+
96+
parts: list[str] = []
97+
if include_bos:
98+
parts.append(TOOL_USE_SPECIAL_TOKEN_TEXT["bos"])
99+
if instruction:
100+
parts.append(instruction.rstrip("\n"))
101+
102+
for block in blocks:
103+
_append_block(parts, block)
104+
105+
if include_eos:
106+
parts.append(TOOL_USE_SPECIAL_TOKEN_TEXT["eos"])
107+
108+
return "\n".join(parts)
109+
110+
111+
def encode_tool_use_template(
112+
tokenizer: Any,
113+
instruction: str,
114+
blocks: Sequence[ToolUseBlock] = (),
115+
*,
116+
include_bos: bool = True,
117+
include_eos: bool = True,
118+
allow_special_tokens: bool = False,
119+
) -> list[int]:
120+
"""Render and encode with a caller-owned tokenizer."""
121+
122+
rendered = render_tool_use_template(
123+
instruction,
124+
blocks,
125+
include_bos=include_bos,
126+
include_eos=include_eos,
127+
allow_special_tokens=allow_special_tokens,
128+
)
129+
encoded = tokenizer.encode(rendered)
130+
if not isinstance(encoded, list) or any(isinstance(item, list) for item in encoded):
131+
raise TypeError("tokenizer.encode(text) must return a flat list[int]")
132+
if any(not isinstance(item, int) or isinstance(item, bool) for item in encoded):
133+
raise TypeError("tokenizer.encode(text) must return integer token IDs")
134+
return encoded
135+
136+
137+
def compute_tool_use_loss_mask(
138+
token_ids: Sequence[int],
139+
special_ids: ToolUseSpecialTokenIds | Mapping[str, int] | None = None,
140+
) -> list[int]:
141+
"""Compute next-token loss mask for cppmega tool-use SFT examples."""
142+
143+
ids = _normalize_special_ids(special_ids)
144+
response_start_tokens = {ids.think_start, ids.code_start, ids.query_tool}
145+
mask = [0] * len(token_ids)
146+
147+
response_start = len(token_ids)
148+
for index, token_id in enumerate(token_ids):
149+
if int(token_id) in response_start_tokens:
150+
response_start = index
151+
break
152+
153+
in_tool_result = False
154+
for index in range(response_start, len(token_ids)):
155+
token_id = int(token_ids[index])
156+
157+
if token_id == ids.bos or token_id == ids.eos:
158+
mask[index] = 0
159+
continue
160+
161+
if token_id == ids.tool_result:
162+
in_tool_result = True
163+
mask[index] = 0
164+
continue
165+
166+
if in_tool_result:
167+
if token_id == ids.code_end:
168+
in_tool_result = False
169+
mask[index] = 0
170+
elif token_id in response_start_tokens:
171+
in_tool_result = False
172+
mask[index] = 1
173+
else:
174+
mask[index] = 0
175+
continue
176+
177+
mask[index] = 1
178+
179+
return mask
180+
181+
182+
def _append_block(parts: list[str], block: ToolUseBlock) -> None:
183+
if block.think is not None:
184+
parts.append(TOOL_USE_SPECIAL_TOKEN_TEXT["think_start"])
185+
if block.think:
186+
parts.append(block.think.rstrip("\n"))
187+
parts.append(TOOL_USE_SPECIAL_TOKEN_TEXT["think_end"])
188+
189+
if block.query_tool is not None:
190+
query = block.query_tool.strip()
191+
parts.append(f'{TOOL_USE_SPECIAL_TOKEN_TEXT["query_tool"]} {query} {TOOL_USE_SPECIAL_TOKEN_TEXT["code_end"]}')
192+
193+
if block.tool_result is not None:
194+
parts.append(TOOL_USE_SPECIAL_TOKEN_TEXT["tool_result"])
195+
if block.tool_result:
196+
parts.append(block.tool_result.rstrip("\n"))
197+
parts.append(TOOL_USE_SPECIAL_TOKEN_TEXT["code_end"])
198+
199+
if block.code is not None:
200+
parts.append(TOOL_USE_SPECIAL_TOKEN_TEXT["code_start"])
201+
if block.code:
202+
parts.append(block.code.rstrip("\n"))
203+
parts.append(TOOL_USE_SPECIAL_TOKEN_TEXT["code_end"])
204+
205+
206+
def _reject_block_reserved_tokens(block_index: int, block: ToolUseBlock) -> None:
207+
for field_name in ("think", "query_tool", "tool_result", "code"):
208+
value = getattr(block, field_name)
209+
if value is not None:
210+
_reject_reserved_tokens(f"blocks[{block_index}].{field_name}", value)
211+
212+
213+
def _reject_reserved_tokens(field_name: str, value: str) -> None:
214+
for token in _RESERVED_TOOL_USE_TOKENS:
215+
if token in value:
216+
raise ValueError(
217+
f"{field_name} contains reserved tool-use token {token!r}; "
218+
"pass allow_special_tokens=True only for trusted preformatted data"
219+
)
220+
221+
222+
def _normalize_special_ids(
223+
special_ids: ToolUseSpecialTokenIds | Mapping[str, int] | None,
224+
) -> ToolUseSpecialTokenIds:
225+
if special_ids is None:
226+
return ToolUseSpecialTokenIds()
227+
if isinstance(special_ids, ToolUseSpecialTokenIds):
228+
return special_ids
229+
return ToolUseSpecialTokenIds.from_mapping(special_ids)
230+
231+
232+
def _lookup_id(mapping: Mapping[str, int], *keys: str) -> int:
233+
for key in keys:
234+
if key in mapping:
235+
value = mapping[key]
236+
if isinstance(value, bool) or not isinstance(value, int):
237+
raise TypeError(f"special token id {key!r} must be an int")
238+
return value
239+
raise ValueError(f"missing tool-use special token id for {keys[0]!r}")
240+
241+
242+
__all__ = [
243+
"TOOL_USE_SPECIAL_TOKEN_TEXT",
244+
"ToolUseBlock",
245+
"ToolUseSpecialTokenIds",
246+
"compute_tool_use_loss_mask",
247+
"encode_tool_use_template",
248+
"render_tool_use_template",
249+
]

cppmega_mlx/recipes/model_factory.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,10 @@ class TokenizerContractStatus:
7373
blocker_id: str = LOCAL_GB10_QUARTER_TOKENIZER_BLOCKER_ID
7474
reason: str = (
7575
"M0.1 is closed: the deployed GB10 65K tokenizer is vendored with "
76-
"id 7=<CODE_START>, id 45=<FIM_INSTRUCTION>, id 46=<SPACE>, and "
77-
"id 47=<NL>; MLX and upstream wrappers use explicit whitespace-sentinel "
78-
"encode/decode with Mac-vs-upstream parity receipts"
76+
"id 7=<CODE_START>, id 8=<CODE_END>, id 11=<QUERY_TOOL>, "
77+
"id 19=<TOOL_RESULT>, id 45=<FIM_INSTRUCTION>, id 46=<SPACE>, "
78+
"and id 47=<NL>; MLX and upstream wrappers use explicit "
79+
"whitespace-sentinel encode/decode with Mac-vs-upstream parity receipts"
7980
)
8081

8182
@property

0 commit comments

Comments
 (0)