Skip to content

Commit 82ab1be

Browse files
committed
feat: implement InferenceSideChannelBuilder with platform and adapter metadata support for model inference
1 parent 4a28d24 commit 82ab1be

9 files changed

Lines changed: 1215 additions & 0 deletions

File tree

cppmega_mlx/inference/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@
4040
build_paged_block_table,
4141
require_model_integrated_paged_attention,
4242
)
43+
from cppmega_mlx.inference.side_channels import (
44+
AdapterCapabilities,
45+
CodeMetadataAdapter,
46+
InferenceFailPolicy,
47+
InferenceSideChannelBuilder,
48+
InferenceSideChannelCacheComponents,
49+
InferenceSideChannelResult,
50+
TokenMetadata,
51+
)
4352
from cppmega_mlx.inference.speculative_decode import (
4453
speculative_acceptance,
4554
speculative_acceptance_batch,
@@ -52,13 +61,20 @@
5261
"ContiguousKVCacheConfig",
5362
"ContinuousBatchScheduler",
5463
"GenerationChunk",
64+
"AdapterCapabilities",
65+
"CodeMetadataAdapter",
66+
"InferenceFailPolicy",
5567
"InferenceQuantizationConfig",
68+
"InferenceSideChannelBuilder",
69+
"InferenceSideChannelCacheComponents",
70+
"InferenceSideChannelResult",
5671
"PAGED_ATTENTION_NOT_INTEGRATED_MESSAGE",
5772
"PagedKVBlockManager",
5873
"PagedKVBlockManagerConfig",
5974
"PromptCacheEntry",
6075
"SchedulerOutput",
6176
"SequenceRequest",
77+
"TokenMetadata",
6278
"build_prompt_cache",
6379
"build_fim_prompt_ids",
6480
"build_paged_block_table",
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
"""Inference-time side-channel enrichment builder."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping, Sequence
6+
from dataclasses import dataclass, field
7+
from hashlib import sha256
8+
import json
9+
from typing import Any, Literal, Protocol, runtime_checkable
10+
11+
import mlx.core as mx
12+
13+
from cppmega_mlx.data.platform_context import (
14+
PlatformContext,
15+
encode_platform_context,
16+
parse_platform_context,
17+
platform_ids_array,
18+
render_platform_context,
19+
)
20+
21+
InferenceFailPolicy = Literal["drop_family", "text_only", "error"]
22+
23+
24+
@dataclass(frozen=True)
25+
class AdapterCapabilities:
26+
"""Declarative metadata advertised by a language adapter."""
27+
28+
language: str
29+
version: str
30+
families: tuple[str, ...] = ()
31+
32+
33+
@dataclass(frozen=True)
34+
class TokenMetadata:
35+
"""Token-coordinate metadata produced by a language adapter."""
36+
37+
structure_ids: Sequence[int] | None = None
38+
dep_levels: Sequence[int] | None = None
39+
ast_depth_ids: Sequence[int] | None = None
40+
sibling_index_ids: Sequence[int] | None = None
41+
node_type_ids: Sequence[int] | None = None
42+
side_channels: Mapping[str, Mapping[str, Sequence[int]]] = field(
43+
default_factory=dict
44+
)
45+
provenance: Mapping[str, str] = field(default_factory=dict)
46+
47+
48+
@runtime_checkable
49+
class CodeMetadataAdapter(Protocol):
50+
"""Language adapter seam for inference-time code metadata."""
51+
52+
language: str
53+
version: str
54+
55+
def probe(self, context: Mapping[str, Any]) -> AdapterCapabilities: ...
56+
57+
def extract(self, source_or_project: str, options: Mapping[str, Any]) -> Any: ...
58+
59+
def map_to_tokens(
60+
self,
61+
metadata: Any,
62+
tokens: Sequence[int],
63+
tokenizer: Any,
64+
) -> TokenMetadata: ...
65+
66+
67+
@dataclass(frozen=True)
68+
class InferenceSideChannelCacheComponents:
69+
"""Stable cache-key parts for enriched inference prompts."""
70+
71+
content_sha256: str
72+
tokenizer_id: str
73+
adapter_language: str | None
74+
adapter_version: str | None
75+
platform_context: str
76+
77+
def to_json(self) -> str:
78+
return json.dumps(
79+
{
80+
"content_sha256": self.content_sha256,
81+
"tokenizer_id": self.tokenizer_id,
82+
"adapter_language": self.adapter_language,
83+
"adapter_version": self.adapter_version,
84+
"platform_context": self.platform_context,
85+
},
86+
sort_keys=True,
87+
separators=(",", ":"),
88+
)
89+
90+
def digest(self) -> str:
91+
return sha256(self.to_json().encode("utf-8")).hexdigest()
92+
93+
94+
@dataclass(frozen=True)
95+
class InferenceSideChannelResult:
96+
"""Prompt IDs plus model kwargs and provenance produced by the builder."""
97+
98+
prompt_ids: mx.array
99+
model_kwargs: Mapping[str, mx.array]
100+
side_channels: Mapping[str, Mapping[str, mx.array]]
101+
provenance: Mapping[str, str]
102+
platform_context: PlatformContext
103+
rendered_platform_context: str
104+
cache_components: InferenceSideChannelCacheComponents
105+
106+
@property
107+
def cache_key(self) -> str:
108+
return self.cache_components.digest()
109+
110+
111+
class InferenceSideChannelBuilder:
112+
"""Build side-channel tensors for a single inference prompt."""
113+
114+
def __init__(
115+
self,
116+
tokenizer: Any,
117+
*,
118+
tokenizer_id: str | None = None,
119+
adapter: CodeMetadataAdapter | None = None,
120+
fail_policy: InferenceFailPolicy = "drop_family",
121+
) -> None:
122+
if fail_policy not in {"drop_family", "text_only", "error"}:
123+
raise ValueError("fail_policy must be drop_family, text_only, or error")
124+
self.tokenizer = tokenizer
125+
self.tokenizer_id = tokenizer_id or _infer_tokenizer_id(tokenizer)
126+
self.adapter = adapter
127+
self.fail_policy = fail_policy
128+
129+
def build(
130+
self,
131+
text: str,
132+
*,
133+
platform_context: PlatformContext | Mapping[str, Any] | str | None = None,
134+
adapter: CodeMetadataAdapter | None = None,
135+
language: str | None = None,
136+
fail_policy: InferenceFailPolicy | None = None,
137+
) -> InferenceSideChannelResult:
138+
if not isinstance(text, str):
139+
raise TypeError("text must be a string")
140+
policy = fail_policy or self.fail_policy
141+
if policy not in {"drop_family", "text_only", "error"}:
142+
raise ValueError("fail_policy must be drop_family, text_only, or error")
143+
144+
token_ids = _encode_text(self.tokenizer, text)
145+
prompt_ids = mx.array([token_ids], dtype=mx.int32)
146+
ctx = parse_platform_context(platform_context)
147+
rendered_context = render_platform_context(ctx)
148+
active_adapter = adapter or self.adapter
149+
cache_components = _cache_components(
150+
text=text,
151+
tokenizer_id=self.tokenizer_id,
152+
adapter=active_adapter,
153+
platform_context=rendered_context,
154+
)
155+
156+
if policy == "text_only":
157+
return InferenceSideChannelResult(
158+
prompt_ids=prompt_ids,
159+
model_kwargs={},
160+
side_channels={},
161+
provenance={"fallback": "text_only"},
162+
platform_context=ctx,
163+
rendered_platform_context=rendered_context,
164+
cache_components=cache_components,
165+
)
166+
167+
model_kwargs: dict[str, mx.array] = {}
168+
side_channels: dict[str, dict[str, mx.array]] = {}
169+
provenance: dict[str, str] = {}
170+
platform_ids = _platform_ids_for_context(ctx)
171+
if platform_ids is not None:
172+
model_kwargs["platform_ids"] = platform_ids
173+
side_channels["platform"] = {"platform_ids": platform_ids}
174+
provenance["platform"] = "platform_context"
175+
176+
if active_adapter is None:
177+
if language is not None:
178+
_handle_adapter_failure(
179+
policy,
180+
"adapter_missing",
181+
provenance=provenance,
182+
model_kwargs=model_kwargs,
183+
side_channels=side_channels,
184+
)
185+
return InferenceSideChannelResult(
186+
prompt_ids=prompt_ids,
187+
model_kwargs=model_kwargs,
188+
side_channels=side_channels,
189+
provenance=provenance,
190+
platform_context=ctx,
191+
rendered_platform_context=rendered_context,
192+
cache_components=cache_components,
193+
)
194+
195+
try:
196+
metadata = active_adapter.extract(
197+
text,
198+
{
199+
"language": language or active_adapter.language,
200+
"platform_context": ctx,
201+
"tokenizer_id": self.tokenizer_id,
202+
},
203+
)
204+
token_metadata = active_adapter.map_to_tokens(
205+
metadata,
206+
token_ids,
207+
self.tokenizer,
208+
)
209+
_merge_token_metadata(
210+
token_metadata,
211+
token_count=len(token_ids),
212+
model_kwargs=model_kwargs,
213+
side_channels=side_channels,
214+
provenance=provenance,
215+
)
216+
if token_metadata.provenance:
217+
provenance.update(dict(token_metadata.provenance))
218+
provenance.setdefault(
219+
"adapter",
220+
f"{active_adapter.language}:{active_adapter.version}",
221+
)
222+
except Exception as exc:
223+
_handle_adapter_failure(
224+
policy,
225+
f"adapter_error:{type(exc).__name__}",
226+
provenance=provenance,
227+
model_kwargs=model_kwargs,
228+
side_channels=side_channels,
229+
)
230+
231+
return InferenceSideChannelResult(
232+
prompt_ids=prompt_ids,
233+
model_kwargs=model_kwargs,
234+
side_channels=side_channels,
235+
provenance=provenance,
236+
platform_context=ctx,
237+
rendered_platform_context=rendered_context,
238+
cache_components=cache_components,
239+
)
240+
241+
242+
def _encode_text(tokenizer: Any, text: str) -> list[int]:
243+
if not hasattr(tokenizer, "encode"):
244+
raise TypeError("tokenizer must expose encode(text)")
245+
encoded = tokenizer.encode(text)
246+
if hasattr(encoded, "ids"):
247+
ids = list(encoded.ids)
248+
else:
249+
ids = list(encoded)
250+
if not ids:
251+
raise ValueError("encoded prompt must contain at least one token")
252+
if any(not isinstance(item, int) for item in ids):
253+
raise ValueError("tokenizer.encode must return integer token ids")
254+
return ids
255+
256+
257+
def _infer_tokenizer_id(tokenizer: Any) -> str:
258+
for attr in ("name_or_path", "path"):
259+
value = getattr(tokenizer, attr, None)
260+
if value:
261+
return str(value)
262+
return f"{tokenizer.__class__.__module__}.{tokenizer.__class__.__qualname__}"
263+
264+
265+
def _cache_components(
266+
*,
267+
text: str,
268+
tokenizer_id: str,
269+
adapter: CodeMetadataAdapter | None,
270+
platform_context: str,
271+
) -> InferenceSideChannelCacheComponents:
272+
return InferenceSideChannelCacheComponents(
273+
content_sha256=sha256(text.encode("utf-8")).hexdigest(),
274+
tokenizer_id=tokenizer_id,
275+
adapter_language=getattr(adapter, "language", None),
276+
adapter_version=getattr(adapter, "version", None),
277+
platform_context=platform_context,
278+
)
279+
280+
281+
def _platform_ids_for_context(ctx: PlatformContext) -> mx.array | None:
282+
if not encode_platform_context(ctx):
283+
return None
284+
return mx.array(platform_ids_array([ctx]), dtype=mx.int32)
285+
286+
287+
def _merge_token_metadata(
288+
metadata: TokenMetadata,
289+
*,
290+
token_count: int,
291+
model_kwargs: dict[str, mx.array],
292+
side_channels: dict[str, dict[str, mx.array]],
293+
provenance: dict[str, str],
294+
) -> None:
295+
field_families = {
296+
"structure_ids": "structure",
297+
"dep_levels": "structure",
298+
"ast_depth_ids": "syntax",
299+
"sibling_index_ids": "syntax",
300+
"node_type_ids": "syntax",
301+
}
302+
for name, family in field_families.items():
303+
value = getattr(metadata, name)
304+
if value is None:
305+
continue
306+
array = _token_aligned_array(name, value, token_count=token_count)
307+
model_kwargs[name] = array
308+
side_channels.setdefault(family, {})[name] = array
309+
provenance.setdefault(family, "adapter")
310+
311+
for family, columns in metadata.side_channels.items():
312+
for name, value in columns.items():
313+
array = _token_aligned_array(name, value, token_count=token_count)
314+
side_channels.setdefault(family, {})[name] = array
315+
provenance.setdefault(family, "adapter")
316+
317+
318+
def _token_aligned_array(
319+
name: str,
320+
value: Sequence[int],
321+
*,
322+
token_count: int,
323+
) -> mx.array:
324+
items = [int(item) for item in value]
325+
if len(items) != token_count:
326+
raise ValueError(
327+
f"{name} must have one value per token: got {len(items)}, expected {token_count}"
328+
)
329+
return mx.array([items], dtype=mx.int32)
330+
331+
332+
def _handle_adapter_failure(
333+
policy: InferenceFailPolicy,
334+
reason: str,
335+
*,
336+
provenance: dict[str, str],
337+
model_kwargs: dict[str, mx.array],
338+
side_channels: dict[str, dict[str, mx.array]],
339+
) -> None:
340+
if policy == "error":
341+
raise RuntimeError(reason)
342+
if policy == "text_only":
343+
model_kwargs.clear()
344+
side_channels.clear()
345+
provenance.clear()
346+
provenance["fallback"] = "text_only"
347+
return
348+
provenance["adapter"] = f"dropped:{reason}"
349+
350+
351+
__all__ = [
352+
"AdapterCapabilities",
353+
"CodeMetadataAdapter",
354+
"InferenceFailPolicy",
355+
"InferenceSideChannelBuilder",
356+
"InferenceSideChannelCacheComponents",
357+
"InferenceSideChannelResult",
358+
"TokenMetadata",
359+
]

0 commit comments

Comments
 (0)