william-ricursive commited on
Commit
5fba98d
·
verified ·
1 Parent(s): 4487a9b

Keep the Spark inference bundle self-contained

Browse files
inference/local/ripple.ipynb CHANGED
@@ -125,7 +125,7 @@
125
  "code_dir = Path.home() / \".cache/ripple/code\"\n",
126
  "snapshot_download(\n",
127
  " TARGET,\n",
128
- " allow_patterns=[\"inference/local/spark.py\", \"inference/local/model.py\", \"inference/server/ripple_format.py\", \"chat_template.jinja\"],\n",
129
  " local_dir=code_dir,\n",
130
  ")\n",
131
  "sys.path.insert(0, str(code_dir / \"inference/local\"))\n",
 
125
  "code_dir = Path.home() / \".cache/ripple/code\"\n",
126
  "snapshot_download(\n",
127
  " TARGET,\n",
128
+ " allow_patterns=[\"inference/local/spark.py\", \"inference/local/model.py\", \"inference/local/ripple_format.py\", \"chat_template.jinja\"],\n",
129
  " local_dir=code_dir,\n",
130
  ")\n",
131
  "sys.path.insert(0, str(code_dir / \"inference/local\"))\n",
inference/local/ripple_format.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ripple's chat format for SGLang: reasoning parser, tool-call parser and tokenizer check.
2
+
3
+ The model generates the layout chat_template.jinja renders:
4
+
5
+ <think>\\n{reasoning}\\n</think>\\n{content}
6
+ <think>\\n{reasoning}\\n</think>\\n\\n<tool_call>\\n{"name": "f", "arguments": {...}}\\n</tool_call>
7
+
8
+ `--reasoning-parser ripple` returns reasoning and content without the separator newlines the
9
+ template adds back, so an assistant message sent back unchanged re-renders to the tokens the
10
+ model generated (SGLang's deepseek-v3 parser keeps them, doubling each newline on every turn).
11
+ Output counts as reasoning only when it opens with <think>.
12
+
13
+ `--tool-call-parser ripple` is hermes with the template's call layout, so strict tools and
14
+ tool_choice=required constrain decoding to `<tool_call>\\n{"name": "f", "arguments": ...}`
15
+ (hermes forces `<tool_call>{"name":"f"`, which the model was not trained on). Text before the
16
+ first call keeps its whitespace, minus the newline the template puts before <tool_call>.
17
+
18
+ Output the template cannot render (`</think>\\n<tool_call>` with one newline, text after a call)
19
+ re-renders in the template's canonical layout.
20
+
21
+ `register()` adds both parsers and checks every tokenizer SGLang loads against the checkpoint's
22
+ tokenizer.json (Transformers 5 can rebuild the pre-tokenizer from `tokenizer_class`).
23
+ """
24
+
25
+ import json
26
+ import logging
27
+
28
+ THINK_START, THINK_END = "<think>", "</think>"
29
+
30
+ # Exercise pre-tokenizer differences: digit runs, contractions, whitespace runs, non-ASCII.
31
+ TOKENIZER_PROBES = (
32
+ "x = 1234567890 + 3.14159; y=-42",
33
+ "I'M here, you'RE there, it's 4:05pm. DON'T",
34
+ "a b c\n\n\n d\t\te \r\n f",
35
+ " def f(self):\n return {'k': [1, 2]}\n",
36
+ "café naïve 日本語テキスト 한국어 🙂👍 Ελληνικά",
37
+ "<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\nok\n</think>\nHello<|im_end|>",
38
+ )
39
+
40
+
41
+ def _drop_newline(text, lead):
42
+ if lead:
43
+ return text[1:] if text.startswith("\n") else text
44
+ return text[:-1] if text.endswith("\n") else text
45
+
46
+
47
+ def _partial_suffix(text, token):
48
+ """Length of the longest suffix of text that is a strict prefix of token."""
49
+ for i in range(min(len(text), len(token) - 1), 0, -1):
50
+ if token.startswith(text[-i:]):
51
+ return i
52
+ return 0
53
+
54
+
55
+ def _reasoning_detector_class():
56
+ from sglang.srt.parser.reasoning_parser import (
57
+ BaseReasoningFormatDetector,
58
+ StreamingParseResult,
59
+ )
60
+
61
+ class RippleReasoningDetector(BaseReasoningFormatDetector):
62
+ def __init__(self, stream_reasoning=True, force_reasoning=False, **kwargs):
63
+ # force_reasoning is ignored: the generation prompt does not open <think>.
64
+ super().__init__(
65
+ THINK_START,
66
+ THINK_END,
67
+ force_reasoning=False,
68
+ stream_reasoning=stream_reasoning,
69
+ reasoning_default="explicit_thinking", # as deepseek-v3
70
+ **kwargs,
71
+ )
72
+ self._state = "start" # start -> lead -> think -> after -> content
73
+
74
+ def _detect_and_parse_impl(self, text):
75
+ if self.continue_final_message:
76
+ return super()._detect_and_parse_impl(text)
77
+ if not text.startswith(THINK_START):
78
+ return StreamingParseResult(normal_text=text)
79
+ body = _drop_newline(text[len(THINK_START):], lead=True)
80
+ reasoning, end, content = body.partition(THINK_END)
81
+ if not end: # truncated inside reasoning
82
+ return StreamingParseResult(reasoning_text=reasoning)
83
+ return StreamingParseResult(
84
+ normal_text=_drop_newline(content, lead=True),
85
+ reasoning_text=_drop_newline(reasoning, lead=False),
86
+ )
87
+
88
+ def _parse_streaming_increment_impl(self, new_text):
89
+ if self.continue_final_message:
90
+ return super()._parse_streaming_increment_impl(new_text)
91
+ self._buffer += new_text
92
+ reasoning, normal = "", ""
93
+ while self._buffer:
94
+ buf = self._buffer
95
+ if self._state == "start":
96
+ if buf.startswith(THINK_START):
97
+ self._buffer, self._state, self._in_reasoning = buf[len(THINK_START):], "lead", True
98
+ elif THINK_START.startswith(buf):
99
+ break
100
+ else:
101
+ self._state = "content"
102
+ elif self._state == "lead":
103
+ self._buffer, self._state = _drop_newline(buf, lead=True), "think"
104
+ elif self._state == "think":
105
+ i = buf.find(THINK_END)
106
+ if i >= 0:
107
+ reasoning += _drop_newline(buf[:i], lead=False)
108
+ self._buffer, self._state, self._in_reasoning = buf[i + len(THINK_END):], "after", False
109
+ continue
110
+ if not self.stream_reasoning:
111
+ break
112
+ # Hold back a possible "\n</think>" prefix: its newline is the separator.
113
+ hold = max(_partial_suffix(buf, "\n" + THINK_END), _partial_suffix(buf, THINK_END))
114
+ reasoning += buf[:len(buf) - hold]
115
+ self._buffer = buf[len(buf) - hold:]
116
+ break
117
+ elif self._state == "after":
118
+ self._buffer, self._state = _drop_newline(buf, lead=True), "content"
119
+ else:
120
+ normal += buf
121
+ self._buffer = ""
122
+ return StreamingParseResult(normal_text=normal, reasoning_text=reasoning)
123
+
124
+ def finish(self):
125
+ if self.continue_final_message:
126
+ return super().finish()
127
+ buf, self._buffer = self._buffer, ""
128
+ if self._state in ("lead", "think"): # truncated inside reasoning
129
+ if self._force_nonempty_content:
130
+ text, self._accumulated_reasoning = self._accumulated_reasoning + buf, ""
131
+ return StreamingParseResult(normal_text=text)
132
+ return StreamingParseResult(reasoning_text=buf)
133
+ return StreamingParseResult(normal_text=buf)
134
+
135
+ return RippleReasoningDetector
136
+
137
+
138
+ def _tool_detector_class():
139
+ from sglang.srt.function_call.core_types import StructureInfo
140
+ from sglang.srt.function_call.hermes_detector import HermesDetector
141
+
142
+ class RippleToolDetector(HermesDetector):
143
+ """Text before the first call keeps its whitespace except the one newline the template
144
+ puts before <tool_call> (hermes strips it all)."""
145
+
146
+ def __init__(self):
147
+ super().__init__()
148
+ self._held_newline = ""
149
+ self._in_calls = False
150
+
151
+ def detect_and_parse(self, text, tools):
152
+ res = super().detect_and_parse(text, tools)
153
+ idx = text.find(self.bot_token)
154
+ if idx != -1 and res.normal_text == text[:idx].strip(): # parsed (not the error path)
155
+ res.normal_text = _drop_newline(text[:idx], lead=False)
156
+ return res
157
+
158
+ def parse_streaming_increment(self, new_text, tools):
159
+ res = super().parse_streaming_increment(new_text, tools)
160
+ if self._in_calls: # text after the first call is dropped, as in detect_and_parse
161
+ res.normal_text = ""
162
+ return res
163
+ call_started = bool(self._buffer.startswith(self.bot_token) or self.current_tool_id >= 0 or res.calls)
164
+ self._in_calls = call_started
165
+ if res.normal_text:
166
+ text, self._held_newline = self._held_newline + res.normal_text, ""
167
+ if call_started: # its trailing newline is the separator before <tool_call>
168
+ text = _drop_newline(text, lead=False)
169
+ elif text.endswith("\n"): # undecided until the next text arrives
170
+ text, self._held_newline = text[:-1], "\n"
171
+ res.normal_text = text
172
+ elif call_started:
173
+ self._held_newline = ""
174
+ return res
175
+
176
+ def finish(self, tools):
177
+ res = super().finish(tools)
178
+ if self._in_calls:
179
+ res.normal_text = ""
180
+ elif self._held_newline:
181
+ res.normal_text, self._held_newline = self._held_newline + (res.normal_text or ""), ""
182
+ return res
183
+
184
+ def structure_info(self):
185
+ return lambda name: StructureInfo(
186
+ begin='<tool_call>\n{"name": ' + json.dumps(name, ensure_ascii=False) + ', "arguments": ',
187
+ end="}\n</tool_call>",
188
+ trigger="<tool_call>",
189
+ )
190
+
191
+ return RippleToolDetector
192
+
193
+
194
+ def check_tokenizer(tokenizer, tokenizer_json):
195
+ """Make `tokenizer` encode and decode like the raw tokenizer.json, or raise.
196
+
197
+ Restores the normalizer, pre-tokenizer and decoder from tokenizer.json when they differ."""
198
+ from tokenizers import Tokenizer
199
+
200
+ raw = Tokenizer.from_file(tokenizer_json)
201
+
202
+ def mismatches():
203
+ bad = []
204
+ for text in TOKENIZER_PROBES:
205
+ ids = raw.encode(text, add_special_tokens=False).ids
206
+ got = tokenizer.encode(text, add_special_tokens=False)
207
+ if got != ids or tokenizer.decode(ids) != raw.decode(ids, skip_special_tokens=False):
208
+ bad.append(text)
209
+ return bad
210
+
211
+ if not mismatches():
212
+ return False
213
+ backend = getattr(tokenizer, "_tokenizer", None)
214
+ if backend is not None:
215
+ backend.normalizer, backend.pre_tokenizer = raw.normalizer, raw.pre_tokenizer
216
+ backend.decoder = raw.decoder
217
+ bad = mismatches()
218
+ if bad:
219
+ raise RuntimeError(
220
+ f"{type(tokenizer).__name__} does not match {tokenizer_json} on {bad!r}; "
221
+ "export the tokenizer with tokenizer_class PreTrainedTokenizerFast"
222
+ )
223
+ return True
224
+
225
+
226
+ def _tokenizer_json(name, revision=None):
227
+ from sglang.srt.utils.hf_transformers import tokenizer as hf_tok
228
+
229
+ try:
230
+ return hf_tok._resolve_local_or_cached_file(name, "tokenizer.json", revision)
231
+ except (OSError, ValueError): # no tokenizer.json (e.g. a slow-only tokenizer)
232
+ return None
233
+
234
+
235
+ def register():
236
+ """Add the `ripple` parsers to SGLang and check tokenizers on load. Idempotent; call at
237
+ import time so SGLang's spawned processes register too."""
238
+ from sglang.srt.function_call.function_call_parser import FunctionCallParser
239
+ from sglang.srt.parser.reasoning_parser import ReasoningParser
240
+ from sglang.srt.utils.hf_transformers import tokenizer as hf_tok
241
+
242
+ ReasoningParser.DetectorMap.setdefault("ripple", _reasoning_detector_class())
243
+ FunctionCallParser.ToolCallParserEnum.setdefault("ripple", _tool_detector_class())
244
+
245
+ fixes = hf_tok._apply_post_load_fixes # fail loudly if SGLang renames it
246
+ if getattr(fixes, "_ripple", False):
247
+ return
248
+
249
+ def apply_post_load_fixes(tokenizer, name, revision=None):
250
+ tokenizer = fixes(tokenizer, name, revision)
251
+ path = _tokenizer_json(name, revision)
252
+ if path is not None and check_tokenizer(tokenizer, path):
253
+ logging.getLogger(__name__).warning(
254
+ "Restored %s's normalizer, pre-tokenizer and decoder from tokenizer.json", name)
255
+ return tokenizer
256
+
257
+ apply_post_load_fixes._ripple = True
258
+ hf_tok._apply_post_load_fixes = apply_post_load_fixes
inference/local/spark.py CHANGED
@@ -5,7 +5,6 @@ import functools
5
  import json
6
  import os
7
  import shlex
8
- import sys
9
  from pathlib import Path
10
 
11
  # Keep the Spark kernel choices used by the original notebook.
@@ -24,8 +23,7 @@ from sglang.srt.server_args import prepare_server_args
24
 
25
  from model import RicursiveForCausalLM
26
 
27
- sys.path.append(str(Path(__file__).resolve().parents[1] / "server"))
28
- import ripple_format # noqa: E402
29
 
30
  ModelRegistry.models["RicursiveForCausalLM"] = RicursiveForCausalLM
31
  ripple_format.register()
 
5
  import json
6
  import os
7
  import shlex
 
8
  from pathlib import Path
9
 
10
  # Keep the Spark kernel choices used by the original notebook.
 
23
 
24
  from model import RicursiveForCausalLM
25
 
26
+ import ripple_format
 
27
 
28
  ModelRegistry.models["RicursiveForCausalLM"] = RicursiveForCausalLM
29
  ripple_format.register()