renderers 0.2.1.dev8__tar.gz → 0.2.1.dev10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/PKG-INFO +2 -2
  2. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/pyproject.toml +2 -2
  3. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/_version.py +2 -2
  4. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/base.py +1 -1
  5. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/client.py +159 -47
  6. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/replay.py +4 -1
  7. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_client.py +68 -10
  8. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/uv.lock +2 -2
  9. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/.github/workflows/publish-dev.yml +0 -0
  10. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/.github/workflows/publish.yml +0 -0
  11. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/.github/workflows/style.yml +0 -0
  12. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/.github/workflows/test.yml +0 -0
  13. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/.gitignore +0 -0
  14. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/.pre-commit-config.yaml +0 -0
  15. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/LICENSE +0 -0
  16. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/README.md +0 -0
  17. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/docs/renderer-config.md +0 -0
  18. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/examples/README.md +0 -0
  19. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/examples/sglang/multiturn_generate_sglang.py +0 -0
  20. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/examples/sglang/online_multiturn_sglang.py +0 -0
  21. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/examples/tinker/multiturn_generate_tinker.py +0 -0
  22. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/examples/transformers/multiturn_generate_transformers.py +0 -0
  23. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/examples/vllm/multiturn_generate_vllm.py +0 -0
  24. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/__init__.py +0 -0
  25. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/configs.py +0 -0
  26. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/custom.py +0 -0
  27. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/deepseek_r1.py +0 -0
  28. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/deepseek_v3.py +0 -0
  29. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/deepseek_v4.py +0 -0
  30. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/default.py +0 -0
  31. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/gemma4.py +0 -0
  32. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/glm45.py +0 -0
  33. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/glm5.py +0 -0
  34. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/gpt_oss.py +0 -0
  35. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/hy3.py +0 -0
  36. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/inkling.py +0 -0
  37. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/kimi_k2.py +0 -0
  38. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/kimi_k25.py +0 -0
  39. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/laguna_s21.py +0 -0
  40. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/laguna_xs2.py +0 -0
  41. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/llama_3.py +0 -0
  42. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/minimax_m2.py +0 -0
  43. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/muse_glimmer.py +0 -0
  44. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/nemotron3.py +0 -0
  45. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/parsers.py +0 -0
  46. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/parsing.py +0 -0
  47. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/prime_qwen3.py +0 -0
  48. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/qwen3.py +0 -0
  49. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/qwen35.py +0 -0
  50. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/qwen36.py +0 -0
  51. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/qwen38.py +0 -0
  52. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/qwen3_vl.py +0 -0
  53. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/reasoning.py +0 -0
  54. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/renderers/tagged.py +0 -0
  55. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/audit_prefix_stability.py +0 -0
  56. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/conftest.py +0 -0
  57. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/fixtures/prefix_stability.json +0 -0
  58. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/model_assets.py +0 -0
  59. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/parity.py +0 -0
  60. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/reference_continuations.py +0 -0
  61. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/reference_rendering.py +0 -0
  62. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_bridge.py +0 -0
  63. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_build_helpers.py +0 -0
  64. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_custom.py +0 -0
  65. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_deepseek_r1.py +0 -0
  66. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_deepseek_v4.py +0 -0
  67. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_disabled_thinking_stability.py +0 -0
  68. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_gemma4.py +0 -0
  69. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_glm53_flash.py +0 -0
  70. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_glm_tool_name_validation.py +0 -0
  71. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_hy3.py +0 -0
  72. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_incremental.py +0 -0
  73. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_inkling.py +0 -0
  74. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_is_content.py +0 -0
  75. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_kimi_k25_tool_schema.py +0 -0
  76. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_laguna_m1.py +0 -0
  77. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_laguna_xs21.py +0 -0
  78. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_llama_3.py +0 -0
  79. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_load_tokenizer.py +0 -0
  80. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_message_indices.py +0 -0
  81. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_message_tool_names.py +0 -0
  82. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_multimodal.py +0 -0
  83. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_muse_glimmer.py +0 -0
  84. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_nemotron35_vlm.py +0 -0
  85. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_nemotron3_ultra.py +0 -0
  86. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_offsetless_tokenizers.py +0 -0
  87. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_parity.py +0 -0
  88. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_parse_response.py +0 -0
  89. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_parse_response_robustness.py +0 -0
  90. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_parsers.py +0 -0
  91. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_preserve_thinking.py +0 -0
  92. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_qwen35_size_coverage.py +0 -0
  93. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_qwen38.py +0 -0
  94. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_reasoning_boundaries.py +0 -0
  95. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_renderer_config.py +0 -0
  96. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_roundtrip.py +0 -0
  97. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_sampled_mask.py +0 -0
  98. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_tagged_rollout.py +0 -0
  99. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_tokens_per_message.py +0 -0
  100. {renderers-0.2.1.dev8 → renderers-0.2.1.dev10}/tests/test_tool_arg_type_preservation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: renderers
3
- Version: 0.2.1.dev8
3
+ Version: 0.2.1.dev10
4
4
  Summary: Chat template renderers — deterministic message-to-token conversion for LLM training
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -12,7 +12,7 @@ Requires-Dist: openai>=1.108.1
12
12
  Requires-Dist: prime-pydantic-config>=0.3.0.dev83
13
13
  Requires-Dist: tiktoken
14
14
  Provides-Extra: multimodal
15
- Requires-Dist: pillow>=12.2.0; extra == 'multimodal'
15
+ Requires-Dist: pillow>=13.0.0; extra == 'multimodal'
16
16
  Requires-Dist: transformers>=4.50.0; extra == 'multimodal'
17
17
  Provides-Extra: transformers
18
18
  Requires-Dist: transformers>=4.50.0; extra == 'transformers'
@@ -52,7 +52,7 @@ transformers = [
52
52
  # Image/audio renderers also need Pillow to resolve media inputs. Keep this as
53
53
  # a separate convenience extra so tokenizer-only users do not pull it in.
54
54
  multimodal = [
55
- "pillow>=12.2.0",
55
+ "pillow>=13.0.0",
56
56
  "transformers>=4.50.0",
57
57
  ]
58
58
 
@@ -96,7 +96,7 @@ packages = ["renderers"]
96
96
 
97
97
  [dependency-groups]
98
98
  dev = [
99
- "pillow>=12.2.0",
99
+ "pillow>=13.0.0",
100
100
  "pre-commit",
101
101
  "pytest>=7.0.0",
102
102
  "pytest-asyncio>=0.21.0",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.1.dev8'
22
- __version_tuple__ = version_tuple = (0, 2, 1, 'dev8')
21
+ __version__ = version = '0.2.1.dev10'
22
+ __version_tuple__ = version_tuple = (0, 2, 1, 'dev10')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -2230,7 +2230,7 @@ def build_trajectory_step(
2230
2230
  "completion_mask": [True] * len(completion_ids),
2231
2231
  "completion_logprobs": [0.0] * len(completion_ids),
2232
2232
  "routed_experts": None,
2233
- "sampling_mask": None,
2233
+ "sampler_support": None,
2234
2234
  }
2235
2235
  if (
2236
2236
  full_rendered.multi_modal_data is not None
@@ -169,9 +169,10 @@ def _parse_packed_completion_logprobs(
169
169
  return values.astype(np.float32, copy=False)
170
170
 
171
171
 
172
- def _parse_compact_logprobs(
173
- choice: Mapping[str, Any], completion_ids: list[int], k: int | None
174
- ) -> tuple[np.ndarray, list[list[int]] | None, list[list[float]] | None]:
172
+ def _decode_compact_logprobs(
173
+ choice: Mapping[str, Any], completion_ids: list[int]
174
+ ) -> tuple[np.ndarray, np.ndarray, list[int], int]:
175
+ """Flat ids and logprobs of every compact row, the row offsets and the head size."""
175
176
  packed = choice.get("compact_logprobs")
176
177
  if not isinstance(packed, Mapping) or packed.get("format") != "u32-f32-le-v1":
177
178
  raise MalformedGenerateResponseError("Unsupported compact logprob format")
@@ -198,13 +199,115 @@ def _parse_compact_logprobs(
198
199
  values = np.frombuffer(raw, dtype="<f4", count=count, offset=count * 4)
199
200
  if not np.isfinite(values).all() or (values > 1e-6).any():
200
201
  raise MalformedGenerateResponseError("Invalid compact logprob values")
202
+ return ids, values, offsets, num_top
203
+
204
+
205
+ def _sampler_support(
206
+ ids: np.ndarray, counts: np.ndarray, logprobs: np.ndarray | None, replayed: bool
207
+ ) -> dict[str, Any]:
208
+ """The sampler's per-token support in CSR form: completion token ``i`` owns ``counts[i]`` entries of
209
+ the flat ``ids`` (and ``logprobs``, the sampler's logprob of each id). ``replayed`` rows are the exact
210
+ set the token was sampled from; other rows are a head of the sampler's distribution."""
211
+ return {
212
+ "ids": ids.astype(np.int32, copy=False),
213
+ "counts": counts.astype(np.int32, copy=False),
214
+ "logprobs": None
215
+ if logprobs is None
216
+ else logprobs.astype(np.float32, copy=False),
217
+ "replayed": replayed,
218
+ }
219
+
220
+
221
+ def _pack_sampler_rows(
222
+ ids: list[list[int]], logprobs: list[list[float]], replayed: bool
223
+ ) -> dict[str, Any]:
224
+ return _sampler_support(
225
+ np.fromiter((i for row in ids for i in row), dtype=np.int32),
226
+ np.fromiter((len(row) for row in ids), dtype=np.int32, count=len(ids)),
227
+ np.fromiter((p for row in logprobs for p in row), dtype=np.float32),
228
+ replayed,
229
+ )
230
+
231
+
232
+ def _sampled_entries(
233
+ ids: np.ndarray, counts: np.ndarray, completion_ids: list[int]
234
+ ) -> np.ndarray:
235
+ """Which entry of each replayed row is the sampled token. Every row must be non-empty, hold distinct
236
+ ids and contain its sampled token."""
237
+ if (
238
+ len(counts) != len(completion_ids)
239
+ or int(counts.sum()) != len(ids)
240
+ or (counts <= 0).any()
241
+ ):
242
+ raise MalformedGenerateResponseError("Sampling mask token count mismatch")
243
+ ids = ids.astype(np.int64)
244
+ rows = np.repeat(np.arange(len(counts)), counts)
245
+ order = np.lexsort((ids, rows))
246
+ sorted_ids, sorted_rows = ids[order], rows[order]
247
+ repeated = (sorted_ids[1:] == sorted_ids[:-1]) & (
248
+ sorted_rows[1:] == sorted_rows[:-1]
249
+ )
250
+ sampled = ids == np.asarray(completion_ids, dtype=np.int64)[rows]
251
+ if repeated.any() or np.count_nonzero(sampled) != len(counts):
252
+ raise MalformedGenerateResponseError("Invalid recorded sampling support")
253
+ return sampled
254
+
255
+
256
+ def _parse_replayed_support(
257
+ choice: Mapping[str, Any], completion_ids: list[int]
258
+ ) -> tuple[np.ndarray, dict[str, Any]]:
259
+ """A replayed request's sampled logprobs and support: the recorded sampling mask with the sampler's
260
+ logprob of every kept id, which must normalize over it."""
261
+ ids, values, offsets, _ = _decode_compact_logprobs(choice, completion_ids)
262
+ mask = choice.get("sampling_mask")
263
+ if not isinstance(mask, Mapping):
264
+ raise MalformedGenerateResponseError("Invalid packed sampling mask")
265
+ mask_ids, counts = _decode_packed_sampling_mask(mask)
266
+ if not np.array_equal(counts, np.diff(offsets)) or not np.array_equal(
267
+ mask_ids, ids
268
+ ):
269
+ raise MalformedGenerateResponseError(
270
+ "Sampler logprobs must cover exactly the recorded sampling support"
271
+ )
272
+ if (values == VLLM_LOGPROB_SENTINEL).any():
273
+ raise MalformedGenerateResponseError("Missing sampling-support probabilities")
274
+ sampled = _sampled_entries(mask_ids, counts, completion_ids)
275
+ if len(counts):
276
+ mass = np.add.reduceat(np.exp(values.astype(np.float64)), offsets[:-1])
277
+ if not np.allclose(mass, 1.0, rtol=0, atol=1e-4):
278
+ raise MalformedGenerateResponseError(
279
+ "Sampler probabilities must normalize over the recorded support"
280
+ )
281
+ return values[sampled], _sampler_support(mask_ids, counts, values, replayed=True)
282
+
283
+
284
+ def _parse_mask_support(
285
+ choice: Mapping[str, Any], completion_ids: list[int]
286
+ ) -> dict[str, Any]:
287
+ """The recorded sampling mask of a request that returned no sampler logprobs for it."""
288
+ mask = choice.get("sampling_mask")
289
+ if isinstance(mask, Mapping):
290
+ ids, counts = _decode_packed_sampling_mask(mask)
291
+ _sampled_entries(ids, counts, completion_ids)
292
+ return _sampler_support(ids, counts, None, replayed=True)
293
+ rows = _parse_sampling_mask(choice, completion_ids)
294
+ assert rows is not None
295
+ return _sampler_support(
296
+ np.fromiter((i for row in rows for i in row), dtype=np.int32),
297
+ np.fromiter((len(row) for row in rows), dtype=np.int32, count=len(rows)),
298
+ None,
299
+ replayed=True,
300
+ )
301
+
302
+
303
+ def _parse_compact_logprobs(
304
+ choice: Mapping[str, Any], completion_ids: list[int], k: int | None
305
+ ) -> tuple[np.ndarray, list[list[int]] | None, list[list[float]] | None]:
306
+ ids, values, offsets, num_top = _decode_compact_logprobs(choice, completion_ids)
201
307
  sampled = []
202
308
  top_ids: list[list[int]] = []
203
309
  top_logprobs: list[list[float]] = []
204
- supports = _parse_sampling_mask(choice, completion_ids) if k is not None else None
205
- for index, (token_id, start, end) in enumerate(
206
- zip(completion_ids, offsets, offsets[1:])
207
- ):
310
+ for token_id, start, end in zip(completion_ids, offsets, offsets[1:]):
208
311
  # FlatLogprobs can repeat the sampled ID. Match vLLM's dict order/value semantics.
209
312
  items = list(zip(ids[start:end].tolist(), values[start:end].tolist()))
210
313
  row = dict(items)
@@ -218,11 +321,7 @@ def _parse_compact_logprobs(
218
321
  if k is not None:
219
322
  head = dict(islice(row.items(), max(num_top, 1)))
220
323
  selected_ids, selected_logprobs = _select_sampler_head(
221
- head,
222
- token_id,
223
- row[token_id],
224
- k,
225
- supports[index] if supports is not None else None,
324
+ head, token_id, row[token_id], k
226
325
  )
227
326
  top_ids.append(selected_ids)
228
327
  top_logprobs.append(selected_logprobs)
@@ -300,6 +399,28 @@ def _parse_completion_logprobs(
300
399
  return np.asarray(completion_logprobs, dtype=np.float32)
301
400
 
302
401
 
402
+ def _decode_packed_sampling_mask(
403
+ mask: Mapping[str, Any],
404
+ ) -> tuple[np.ndarray, np.ndarray]:
405
+ arrays = {}
406
+ for name in ("ids", "counts"):
407
+ packed = mask.get(name)
408
+ if not isinstance(packed, Mapping) or packed.get("dtype") != "int32":
409
+ raise MalformedGenerateResponseError("Invalid packed sampling mask")
410
+ try:
411
+ values = np.frombuffer(
412
+ base64.b64decode(packed["data"], validate=True), dtype=np.int32
413
+ )
414
+ except (KeyError, TypeError, ValueError, binascii.Error) as exc:
415
+ raise MalformedGenerateResponseError(
416
+ "Invalid packed sampling mask"
417
+ ) from exc
418
+ if packed.get("shape") != [len(values)] or (values < 0).any():
419
+ raise MalformedGenerateResponseError("Invalid packed sampling mask")
420
+ arrays[name] = values
421
+ return arrays["ids"], arrays["counts"]
422
+
423
+
303
424
  def _parse_sampling_mask(
304
425
  choice: Mapping[str, Any], completion_ids: list[int]
305
426
  ) -> list[list[int]] | None:
@@ -307,23 +428,7 @@ def _parse_sampling_mask(
307
428
  if mask is None:
308
429
  return None
309
430
  if isinstance(mask, Mapping):
310
- arrays = {}
311
- for name in ("ids", "counts"):
312
- packed = mask.get(name)
313
- if not isinstance(packed, Mapping) or packed.get("dtype") != "int32":
314
- raise MalformedGenerateResponseError("Invalid packed sampling mask")
315
- try:
316
- values = np.frombuffer(
317
- base64.b64decode(packed["data"], validate=True), dtype=np.int32
318
- )
319
- except (KeyError, TypeError, ValueError, binascii.Error) as exc:
320
- raise MalformedGenerateResponseError(
321
- "Invalid packed sampling mask"
322
- ) from exc
323
- if packed.get("shape") != [len(values)] or (values < 0).any():
324
- raise MalformedGenerateResponseError("Invalid packed sampling mask")
325
- arrays[name] = values
326
- counts, ids = arrays["counts"], arrays["ids"]
431
+ ids, counts = _decode_packed_sampling_mask(mask)
327
432
  if len(counts) != len(completion_ids) or int(counts.sum()) != len(ids):
328
433
  raise MalformedGenerateResponseError("Sampling mask token count mismatch")
329
434
  mask = [row.tolist() for row in np.split(ids, np.cumsum(counts)[:-1])]
@@ -500,7 +605,7 @@ async def generate(
500
605
  Returns a dict with: request_id, prompt_ids, renderer_prompt_ids,
501
606
  mm_placeholders, completion_ids, completion_logprobs, content,
502
607
  reasoning_content, tool_calls, finish_reason, routed_experts,
503
- weight_versions, multi_modal_data, prompt_attribution.
608
+ sampler_support, weight_versions, multi_modal_data, prompt_attribution.
504
609
  ``completion_logprobs`` is a float32 numpy array, one value per completion
505
610
  token: vLLM samples in float32, so the packed form decodes without a copy
506
611
  and the JSON forms narrow losslessly.
@@ -509,9 +614,13 @@ async def generate(
509
614
  is vLLM's (>= 0.32) list of ``{version, start, end}`` spans over
510
615
  completion-token indexes, or ``None`` when the engine labels none.
511
616
 
512
- When ``sampling_params["logprobs"]`` is a positive int, also
513
- ``completion_top_ids`` / ``completion_top_logprobs``
514
- (original sampler probabilities, retaining full-vocabulary normalization).
617
+ ``sampler_support`` is the sampler's per-token support in CSR form, or ``None`` when
618
+ the engine returned none: ``counts[i]`` entries of the flat int32 ``ids`` (and float32
619
+ ``logprobs``, or ``None``) per completion token. With ``replayed`` the rows are the
620
+ recorded sampling masks of truncated sampling, with logprobs normalized over each
621
+ mask when the engine returned them. Otherwise, when ``sampling_params["logprobs"]``
622
+ is a positive int, the rows are that many top candidates of the sampler's
623
+ distribution, independent of the sampled token and full-vocabulary normalized.
515
624
 
516
625
  ``prompt_attribution`` is the renderer's :class:`RenderedTokens` for
517
626
  the prompt — either the one this call computed via
@@ -646,18 +755,27 @@ async def generate(
646
755
  "Engine response must include mm_placeholders when process_multimodal=False."
647
756
  )
648
757
 
649
- completion_top_ids = None
650
- completion_top_logprobs = None
651
- if choice.get("compact_logprobs") is not None:
652
- completion_logprobs, completion_top_ids, completion_top_logprobs = (
653
- _parse_compact_logprobs(choice, completion_ids, head_size)
758
+ sampler_support = None
759
+ replayed = choice.get("sampling_mask") is not None
760
+ if choice.get("compact_logprobs") is not None and replayed:
761
+ completion_logprobs, sampler_support = _parse_replayed_support(
762
+ choice, completion_ids
763
+ )
764
+ elif choice.get("compact_logprobs") is not None:
765
+ completion_logprobs, top_ids, top_logprobs = _parse_compact_logprobs(
766
+ choice, completion_ids, head_size
654
767
  )
768
+ if top_ids is not None and top_logprobs is not None:
769
+ sampler_support = _pack_sampler_rows(top_ids, top_logprobs, replayed=False)
655
770
  else:
656
771
  completion_logprobs = _parse_completion_logprobs(choice, completion_ids)
657
772
  if head_size is not None:
658
- completion_top_ids, completion_top_logprobs = (
659
- _parse_completion_top_logprobs(choice, completion_ids, head_size)
773
+ sampler_support = _pack_sampler_rows(
774
+ *_parse_completion_top_logprobs(choice, completion_ids, head_size),
775
+ replayed=replayed,
660
776
  )
777
+ elif replayed:
778
+ sampler_support = _parse_mask_support(choice, completion_ids)
661
779
 
662
780
  parsed = renderer.parse_response(
663
781
  completion_ids,
@@ -666,10 +784,6 @@ async def generate(
666
784
  )
667
785
 
668
786
  routed_experts = choice.get("routed_experts")
669
- # vLLM's native kept-set sampling masks (``--return-sampling-mask``):
670
- # one list of surviving vocab ids per completion token, or (prime-rl's
671
- # server) packed CSR ``{"ids", "counts"}`` base64 int32 arrays.
672
- sampling_mask = choice.get("sampling_mask")
673
787
 
674
788
  # /inference/v1/generate returns finish_reason in {"stop","length",...} —
675
789
  # never "tool_calls" (a chat-completions concept). Promote stop→tool_calls
@@ -694,15 +808,13 @@ async def generate(
694
808
  "mm_placeholders": mm_placeholders,
695
809
  "completion_ids": list(completion_ids),
696
810
  "completion_logprobs": completion_logprobs,
697
- "completion_top_ids": completion_top_ids,
698
- "completion_top_logprobs": completion_top_logprobs,
811
+ "sampler_support": sampler_support,
699
812
  "content": parsed.content,
700
813
  "reasoning_content": parsed.reasoning_content,
701
814
  "tool_calls": parsed.tool_calls,
702
815
  "finish_reason": finish_reason,
703
816
  "reasoning_complete": parsed.reasoning_complete,
704
817
  "routed_experts": routed_experts,
705
- "sampling_mask": sampling_mask,
706
818
  "weight_versions": choice.get("weight_versions"),
707
819
  # The mm sidecar consumed on the request side, surfaced back so
708
820
  # callers can persist it on the trajectory step for downstream
@@ -45,4 +45,7 @@ def replay_client(completions, requests):
45
45
  def assert_evidence(result, ids):
46
46
  assert result["completion_ids"] == ids
47
47
  assert result["completion_logprobs"].tolist() == [-0.25] * len(ids)
48
- assert result["sampling_mask"] == [[tid] for tid in ids]
48
+ support = result["sampler_support"]
49
+ assert support["replayed"] and support["logprobs"] is None
50
+ assert support["ids"].tolist() == ids
51
+ assert support["counts"].tolist() == [1] * len(ids)
@@ -16,6 +16,7 @@ from renderers.base import (
16
16
  from renderers.client import (
17
17
  _parse_compact_logprobs,
18
18
  _parse_completion_top_logprobs,
19
+ _parse_replayed_support,
19
20
  generate,
20
21
  parse_generate_response,
21
22
  )
@@ -59,6 +60,64 @@ def _parse_test_head(choice, sampled, k, compact):
59
60
  return _parse_completion_top_logprobs(choice, sampled, k)
60
61
 
61
62
 
63
+ def _replayed_choice(rows, sampled):
64
+ """A paired replay response: each row's kept ids and their sampler probabilities."""
65
+ ids = np.asarray([i for row in rows for i, _ in row], dtype=np.int32)
66
+ counts = np.asarray([len(row) for row in rows], dtype=np.int32)
67
+ values = np.log(np.asarray([p for row in rows for _, p in row], dtype=np.float32))
68
+ return {
69
+ "token_ids": sampled,
70
+ "compact_logprobs": {
71
+ "data": base64.b64encode(
72
+ ids.astype("<u4").tobytes() + values.astype("<f4").tobytes()
73
+ ).decode("ascii"),
74
+ "format": "u32-f32-le-v1",
75
+ "offsets": [0, *np.cumsum(counts).tolist()],
76
+ "num_top_logprobs": int(counts.max(initial=0)),
77
+ },
78
+ "sampling_mask": {
79
+ name: {
80
+ "data": base64.b64encode(array.tobytes()).decode(),
81
+ "shape": [len(array)],
82
+ "dtype": "int32",
83
+ }
84
+ for name, array in (("ids", ids), ("counts", counts))
85
+ },
86
+ }
87
+
88
+
89
+ @pytest.mark.parametrize(
90
+ "error", [None, "mass", "unsampled", "duplicate", "mask", "empty"]
91
+ )
92
+ def test_replayed_support_keeps_the_recorded_mask_with_its_probabilities(error):
93
+ rows = [[(3, 0.2), (1, 0.6), (2, 0.2)], [(9, 1.0)]]
94
+ sampled = [3, 9]
95
+ if error == "mass":
96
+ rows[0][1] = (1, 0.5)
97
+ elif error == "unsampled":
98
+ sampled = [4, 9]
99
+ elif error == "duplicate":
100
+ rows[0] = [(3, 0.2), (1, 0.6), (3, 0.2)]
101
+ choice = _replayed_choice(rows, sampled)
102
+ if error == "mask":
103
+ choice["sampling_mask"] = _replayed_choice(
104
+ [[(3, 0.2), (1, 0.6), (5, 0.2)], [(9, 1.0)]], sampled
105
+ )["sampling_mask"]
106
+ elif error == "empty":
107
+ choice = _replayed_choice([], [])
108
+ if error not in (None, "empty"):
109
+ with pytest.raises(MalformedGenerateResponseError):
110
+ _parse_replayed_support(choice, sampled)
111
+ return
112
+ logprobs, support = _parse_replayed_support(choice, choice["token_ids"])
113
+ expected = [] if error == "empty" else [0.2, 1.0]
114
+ np.testing.assert_allclose(np.exp(logprobs), expected, rtol=1e-6)
115
+ assert support["replayed"]
116
+ assert support["ids"].tolist() == [i for row in rows for i, _ in row] * (not error)
117
+ assert support["counts"].tolist() == [len(row) for row in rows] * (not error)
118
+ assert len(support["logprobs"]) == len(support["ids"])
119
+
120
+
62
121
  @pytest.mark.parametrize(
63
122
  "update",
64
123
  [
@@ -168,10 +227,9 @@ def test_top_logprobs_head_is_independent_of_sampled_action(
168
227
  _parse_test_head({"logprobs": {"content": [entry]}}, [sampled], 2, compact)
169
228
 
170
229
 
171
- @pytest.mark.parametrize("compact", [False, True])
172
230
  @pytest.mark.parametrize("packed", [False, True])
173
231
  @pytest.mark.parametrize("error", [None, "missing", "mass", "mask"])
174
- def test_top_logprobs_preserve_complete_replay_support(compact, packed, error):
232
+ def test_top_logprobs_preserve_complete_replay_support(packed, error):
175
233
  import math
176
234
 
177
235
  probabilities = {1: 0.6, 2: 0.2, 3: 0.2}
@@ -200,9 +258,9 @@ def test_top_logprobs_preserve_complete_replay_support(compact, packed, error):
200
258
  choice = {"logprobs": {"content": [entry]}, "sampling_mask": mask}
201
259
  if error is not None:
202
260
  with pytest.raises(MalformedGenerateResponseError):
203
- _parse_test_head(choice, [3], 2, compact)
261
+ _parse_completion_top_logprobs(choice, [3], 2)
204
262
  else:
205
- ids, logprobs = _parse_test_head(choice, [3], 2, compact)
263
+ ids, logprobs = _parse_completion_top_logprobs(choice, [3], 2)
206
264
  assert ids == [[1, 2, 3]]
207
265
  np.testing.assert_allclose(np.exp(logprobs), [[0.6, 0.2, 0.2]])
208
266
 
@@ -425,13 +483,13 @@ def test_generate_builds_request_body_and_parses_response(
425
483
  assert result["completion_ids"] == [7, 8]
426
484
  np.testing.assert_allclose(result["completion_logprobs"], [-0.1, -0.2])
427
485
  if head_size is not None:
428
- assert result["completion_top_ids"] == [[7, 20], [8, 20]]
429
- np.testing.assert_allclose(
430
- result["completion_top_logprobs"], [[-0.1, -4.0], [-0.2, -4.0]]
431
- )
486
+ support = result["sampler_support"]
487
+ assert not support["replayed"]
488
+ assert support["ids"].tolist() == [7, 20, 8, 20]
489
+ assert support["counts"].tolist() == [2, 2]
490
+ np.testing.assert_allclose(support["logprobs"], [-0.1, -4.0, -0.2, -4.0])
432
491
  else:
433
- assert result["completion_top_ids"] is None
434
- assert result["completion_top_logprobs"] is None
492
+ assert result["sampler_support"] is None
435
493
  if extra_args is not None:
436
494
  assert extra_args == {"prl_compact_logprobs": False, "custom_arg": 7}
437
495
  assert result["routed_experts"]["shape"] == [2, 1, 1]
@@ -1385,7 +1385,7 @@ requires-dist = [
1385
1385
  { name = "numpy" },
1386
1386
  { name = "openai", specifier = ">=1.108.1" },
1387
1387
  { name = "openai-harmony", specifier = ">=0.0.4" },
1388
- { name = "pillow", marker = "extra == 'multimodal'", specifier = ">=12.2.0" },
1388
+ { name = "pillow", marker = "extra == 'multimodal'", specifier = ">=13.0.0" },
1389
1389
  { name = "prime-pydantic-config", specifier = ">=0.3.0.dev83" },
1390
1390
  { name = "tiktoken" },
1391
1391
  { name = "transformers", marker = "extra == 'multimodal'", specifier = ">=4.50.0" },
@@ -1395,7 +1395,7 @@ provides-extras = ["multimodal", "transformers"]
1395
1395
 
1396
1396
  [package.metadata.requires-dev]
1397
1397
  dev = [
1398
- { name = "pillow", specifier = ">=12.2.0" },
1398
+ { name = "pillow", specifier = ">=13.0.0" },
1399
1399
  { name = "pre-commit" },
1400
1400
  { name = "pytest", specifier = ">=7.0.0" },
1401
1401
  { name = "pytest-asyncio", specifier = ">=0.21.0" },
File without changes