Skip to content

Commit 6421e50

Browse files
committed
feat(vbgui-fg): tokenizer playground — encode_visualize + side-by-side panel
Stage F-G of the Visual Builder GUI epic (cppmega-mlx-o0k). Backend endpoints + React panel that load any HF-style tokenizer.json and emit per-token byte spans, with up to 3 panels side-by-side for A/B/C comparison. Backend (cppmega_v4.jsonrpc.tokenizer_methods): - tokenizer.encode_visualize — loads tokenizer.json via HF tokenizers Python lib, encodes input text, emits TokenSpan list (id / literal / start / end / is_special) plus bytes-per-token avg + max and the tokenizer's capability snapshot (vocab size, special-id contract, decoder kind). Per-source LRU cache. - tokenizer.list_presets — returns the 12-entry preset library (cppmega_v3 / nanochat_v3 / gpt-4-o200k / gpt-3.5-cl100k / llama-3 / mistral / gemma / qwen / deepseek-v3 / phi-3 / claude / gpt-2-p50k). - Dispatcher: both methods wired; METHOD_REGISTRY locks the names. Frontend (vbgui/src/components/TokenizerPlayground.tsx): - Single textarea drives N panels (cap 3 via maxPanels prop). - Each panel: source input + Encode button + metrics line + color-chip grid keyed on token id (specials get a red chip). - Cross-panel hover highlight: hover a chip in one panel → every chip in every panel whose byte span overlaps gets a blue ring (cross- panel highlight bus per VBPlan §9.5.3, no global state — props). - Error envelope from RpcClient surfaces inline per panel. Tests: - Python (11): encode round-trip, special detection (BOS in vendored tokenizer), capability surface, cache hit, sub-50ms warm latency, missing-source error, list_presets shape, dispatcher round-trip for both endpoints, METHOD_REGISTRY lock. - TypeScript (6): renders input + add button, initialSources → panels, maxPanels cap, Encode populates chips + metrics, Remove drops panel, error envelope surfaces inline. Full v4 regression: 2079 passed / 5 skipped / 15 xfailed / 0 failed. Vbgui: 71 tests passed (65 + 6 new). Closes cppmega-mlx-o0k.7.
1 parent b927f9d commit 6421e50

6 files changed

Lines changed: 621 additions & 0 deletions

File tree

cppmega_v4/jsonrpc/dispatcher.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
suggest_sharding,
2323
verify,
2424
)
25+
from cppmega_v4.jsonrpc.tokenizer_methods import (
26+
EncodeVisualizeParams,
27+
encode_visualize,
28+
list_presets as tokenizer_list_presets,
29+
)
2530
from cppmega_v4.jsonrpc.schema import (
2631
BuildPresetSpecsParams,
2732
ErrorCode,
@@ -67,6 +72,10 @@
6772
PipelineRunParams,
6873
lambda p, c: _pipeline_run(p),
6974
),
75+
"tokenizer.encode_visualize": (
76+
EncodeVisualizeParams,
77+
lambda p, c: encode_visualize(p, cache=c),
78+
),
7079
}
7180

7281

@@ -115,6 +124,12 @@ def dispatch(
115124
if request.method == "backend.status":
116125
return JsonRpcResponse(id=request.id, result={"status": "ok"})
117126

127+
if request.method == "tokenizer.list_presets":
128+
return JsonRpcResponse(
129+
id=request.id,
130+
result=tokenizer_list_presets().model_dump(mode="json"),
131+
)
132+
118133
route = _ROUTES.get(request.method)
119134
if route is None:
120135
return _error_response(

cppmega_v4/jsonrpc/schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,4 +532,6 @@ class PipelineRunResult(BaseModel):
532532
"probe.run",
533533
"pipeline.run",
534534
"backend.status",
535+
"tokenizer.encode_visualize",
536+
"tokenizer.list_presets",
535537
})
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Backend tokenizer playground — F-G handlers.
2+
3+
Implements ``tokenizer.encode_visualize`` and ``tokenizer.list_presets``
4+
JSON-RPC methods. Backed by the HuggingFace ``tokenizers`` Python
5+
library so any ``tokenizer.json`` source works uniformly. Tiktoken /
6+
SentencePiece formats are folded in later via the runtime registry on
7+
the frontend (lazy-loaded WASM); the backend exposes one shape.
8+
9+
The encode_visualize result carries per-token byte spans + decoded
10+
literals so the GUI can paint char-aligned color chips and emit
11+
hover-cross-panel highlight events.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import time
17+
from dataclasses import dataclass
18+
from pathlib import Path
19+
from typing import Any
20+
21+
from pydantic import BaseModel, ConfigDict, Field
22+
from tokenizers import Tokenizer
23+
24+
from cppmega_v4.jsonrpc.cache import LRUCache
25+
from cppmega_v4.probe import TokenizerCapabilities, introspect_tokenizer
26+
27+
28+
# Built-in preset library — paths are placeholders that map to real
29+
# tokenizer.json files when shipped via the widget bundle. Resolving
30+
# real paths is the GUI's job; the backend just declares the names.
31+
PRESET_LIBRARY: tuple[str, ...] = (
32+
"cppmega_v3", "nanochat_v3",
33+
"gpt-4-o200k", "gpt-3.5-cl100k", "gpt-2-p50k",
34+
"llama-3", "mistral", "gemma", "qwen",
35+
"deepseek-v3", "phi-3", "claude",
36+
)
37+
38+
39+
# ---------------------------------------------------------------------------
40+
# Schemas
41+
# ---------------------------------------------------------------------------
42+
43+
44+
class TokenSpan(BaseModel):
45+
model_config = ConfigDict(extra="forbid")
46+
id: int
47+
text: str
48+
start: int
49+
end: int
50+
is_special: bool = False
51+
52+
53+
class EncodeVisualizeParams(BaseModel):
54+
model_config = ConfigDict(extra="forbid")
55+
tokenizer_source: str
56+
text: str
57+
add_special_tokens: bool = True
58+
59+
60+
class EncodeVisualizeResult(BaseModel):
61+
model_config = ConfigDict(extra="forbid")
62+
tokens: list[TokenSpan]
63+
token_count: int
64+
bytes_total: int
65+
bytes_per_token_avg: float
66+
bytes_per_token_max: int
67+
capabilities: dict[str, Any]
68+
elapsed_ms: float
69+
70+
71+
class ListPresetsResult(BaseModel):
72+
model_config = ConfigDict(extra="forbid")
73+
presets: list[str] = Field(default_factory=list)
74+
75+
76+
# ---------------------------------------------------------------------------
77+
# Tokenizer cache — separate from the main RPC cache because keys differ.
78+
# ---------------------------------------------------------------------------
79+
80+
81+
@dataclass
82+
class _LoadedTokenizer:
83+
tokenizer: Tokenizer
84+
capabilities: TokenizerCapabilities
85+
86+
87+
_TOKENIZER_CACHE: dict[str, _LoadedTokenizer] = {}
88+
89+
90+
def _load(source: str) -> _LoadedTokenizer:
91+
hit = _TOKENIZER_CACHE.get(source)
92+
if hit is not None:
93+
return hit
94+
path = Path(source)
95+
if not path.is_file():
96+
raise FileNotFoundError(f"tokenizer source not found: {source}")
97+
tok = Tokenizer.from_file(str(path))
98+
caps = introspect_tokenizer(path)
99+
loaded = _LoadedTokenizer(tokenizer=tok, capabilities=caps)
100+
_TOKENIZER_CACHE[source] = loaded
101+
return loaded
102+
103+
104+
# ---------------------------------------------------------------------------
105+
# Handlers
106+
# ---------------------------------------------------------------------------
107+
108+
109+
def encode_visualize(
110+
params: EncodeVisualizeParams,
111+
*,
112+
cache: LRUCache | None = None,
113+
) -> EncodeVisualizeResult:
114+
"""Encode ``text`` through ``tokenizer_source`` and emit per-token spans."""
115+
cache_key = _cache_key(params)
116+
if cache is not None:
117+
hit = cache.get(cache_key)
118+
if hit is not None:
119+
return hit
120+
121+
t0 = time.perf_counter()
122+
loaded = _load(params.tokenizer_source)
123+
encoded = loaded.tokenizer.encode(
124+
params.text, add_special_tokens=params.add_special_tokens,
125+
)
126+
127+
text_bytes = params.text.encode("utf-8")
128+
special_ids = set(loaded.capabilities.special_ids.values())
129+
130+
spans: list[TokenSpan] = []
131+
max_bytes = 0
132+
for tok_id, tok_text, (start, end) in zip(
133+
encoded.ids, encoded.tokens, encoded.offsets,
134+
):
135+
span_bytes = max(0, end - start)
136+
max_bytes = max(max_bytes, span_bytes)
137+
spans.append(TokenSpan(
138+
id=int(tok_id),
139+
text=tok_text,
140+
start=int(start),
141+
end=int(end),
142+
is_special=int(tok_id) in special_ids,
143+
))
144+
145+
avg = (sum((s.end - s.start) for s in spans) / len(spans)) if spans else 0.0
146+
elapsed = (time.perf_counter() - t0) * 1000.0
147+
out = EncodeVisualizeResult(
148+
tokens=spans,
149+
token_count=len(spans),
150+
bytes_total=len(text_bytes),
151+
bytes_per_token_avg=round(avg, 3),
152+
bytes_per_token_max=max_bytes,
153+
capabilities=_caps_to_dict(loaded.capabilities),
154+
elapsed_ms=elapsed,
155+
)
156+
if cache is not None:
157+
cache.set(cache_key, out)
158+
return out
159+
160+
161+
def list_presets() -> ListPresetsResult:
162+
return ListPresetsResult(presets=list(PRESET_LIBRARY))
163+
164+
165+
# ---------------------------------------------------------------------------
166+
# Helpers
167+
# ---------------------------------------------------------------------------
168+
169+
170+
def _cache_key(p: EncodeVisualizeParams) -> str:
171+
# No layout fields here, so canonical JSON via sorted-keys is fine.
172+
return f"tokenize::{p.tokenizer_source}::{p.add_special_tokens}::{hash(p.text)}"
173+
174+
175+
def _caps_to_dict(c: TokenizerCapabilities) -> dict[str, Any]:
176+
return {
177+
"vocab_size": c.vocab_size,
178+
"special_ids": dict(c.special_ids),
179+
"has_fim": c.has_fim,
180+
"has_space_nl": c.has_space_nl,
181+
"has_code_start": c.has_code_start,
182+
"has_instruction": c.has_instruction,
183+
"byte_roundtrip": c.byte_roundtrip,
184+
"decoder_kind": c.decoder_kind,
185+
"source": c.source,
186+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""F-G tokenizer playground tests — encode_visualize + list_presets."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import time
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
from cppmega_v4.jsonrpc import LRUCache, dispatch
12+
from cppmega_v4.jsonrpc.tokenizer_methods import (
13+
EncodeVisualizeParams,
14+
PRESET_LIBRARY,
15+
encode_visualize,
16+
list_presets,
17+
)
18+
19+
20+
_VENDORED = "cppmega_mlx/tokenizer/tokenizer.json"
21+
22+
23+
# ---------------------------------------------------------------------------
24+
# encode_visualize
25+
# ---------------------------------------------------------------------------
26+
27+
28+
def test_encode_visualize_returns_spans():
29+
p = EncodeVisualizeParams(tokenizer_source=_VENDORED, text="hello world")
30+
r = encode_visualize(p)
31+
assert r.token_count > 0
32+
assert all(s.start >= 0 and s.end >= s.start for s in r.tokens)
33+
assert r.bytes_total == len("hello world".encode("utf-8"))
34+
35+
36+
def test_encode_visualize_marks_specials_when_present():
37+
p = EncodeVisualizeParams(tokenizer_source=_VENDORED, text="<BOS>code")
38+
r = encode_visualize(p, cache=LRUCache())
39+
# At least one token id should be in the special-id set (BOS = 2).
40+
assert any(s.is_special for s in r.tokens)
41+
42+
43+
def test_encode_visualize_capabilities_match_vendored_tokenizer():
44+
p = EncodeVisualizeParams(tokenizer_source=_VENDORED, text="x")
45+
r = encode_visualize(p)
46+
caps = r.capabilities
47+
assert caps["vocab_size"] > 0
48+
assert caps["has_fim"] is True
49+
assert caps["has_space_nl"] is True
50+
51+
52+
def test_encode_visualize_caches_repeat_calls():
53+
cache = LRUCache(capacity=4)
54+
p = EncodeVisualizeParams(tokenizer_source=_VENDORED, text="abc")
55+
encode_visualize(p, cache=cache)
56+
encode_visualize(p, cache=cache)
57+
assert cache.stats()["hits"] >= 1
58+
59+
60+
def test_encode_visualize_under_50ms_short_text():
61+
p = EncodeVisualizeParams(tokenizer_source=_VENDORED, text="short input")
62+
# Warm tokenizer cache
63+
encode_visualize(p)
64+
t0 = time.perf_counter()
65+
encode_visualize(p)
66+
elapsed_ms = (time.perf_counter() - t0) * 1000.0
67+
assert elapsed_ms < 50, f"warm encode took {elapsed_ms:.1f} ms"
68+
69+
70+
def test_encode_visualize_missing_source_raises():
71+
p = EncodeVisualizeParams(tokenizer_source="/nonexistent/tok.json", text="x")
72+
with pytest.raises(FileNotFoundError):
73+
encode_visualize(p)
74+
75+
76+
# ---------------------------------------------------------------------------
77+
# list_presets
78+
# ---------------------------------------------------------------------------
79+
80+
81+
def test_list_presets_returns_known_library():
82+
r = list_presets()
83+
assert len(r.presets) == len(PRESET_LIBRARY)
84+
assert "gpt-4-o200k" in r.presets
85+
assert "cppmega_v3" in r.presets
86+
87+
88+
# ---------------------------------------------------------------------------
89+
# Dispatcher integration
90+
# ---------------------------------------------------------------------------
91+
92+
93+
def test_dispatch_tokenizer_encode_visualize_round_trip():
94+
resp = dispatch({
95+
"jsonrpc": "2.0", "id": "t1",
96+
"method": "tokenizer.encode_visualize",
97+
"params": {"tokenizer_source": _VENDORED, "text": "abc"},
98+
})
99+
assert resp.error is None
100+
assert resp.result["token_count"] >= 1
101+
102+
103+
def test_dispatch_tokenizer_list_presets_round_trip():
104+
resp = dispatch({
105+
"jsonrpc": "2.0", "id": "lp", "method": "tokenizer.list_presets",
106+
})
107+
assert resp.error is None
108+
assert "presets" in resp.result
109+
assert len(resp.result["presets"]) >= 12
110+
111+
112+
def test_dispatch_tokenizer_missing_source_returns_invalid_params():
113+
resp = dispatch({
114+
"jsonrpc": "2.0", "id": "tx",
115+
"method": "tokenizer.encode_visualize",
116+
"params": {"tokenizer_source": "/nope.json", "text": "x"},
117+
})
118+
# FileNotFoundError surfaces as INTERNAL_ERROR (not InvalidParams)
119+
# because it's a runtime failure, not a schema failure.
120+
assert resp.error is not None
121+
assert resp.error.code in (-32603, -32602)
122+
123+
124+
def test_method_registry_includes_tokenizer_endpoints():
125+
from cppmega_v4.jsonrpc import METHOD_REGISTRY
126+
assert "tokenizer.encode_visualize" in METHOD_REGISTRY
127+
assert "tokenizer.list_presets" in METHOD_REGISTRY

0 commit comments

Comments
 (0)