From cdc158e72fd223b2896f31089f989b002d1fc8aa Mon Sep 17 00:00:00 2001 From: eexwhyzee <13672394+eexwhyzee@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:38:24 -0700 Subject: [PATCH 1/2] feat(laguna): route poolside/Laguna-S-2.1 to the XS-2.1 renderer S-2.1 is a larger sibling of XS-2.1 (48 layers, hidden 3072, top-10 of 256 experts) but ships a byte-identical chat_template.jinja and tokenizer (same special tokens/ids, same vocab). It reuses LagunaXS21Renderer via an exact-match MODEL_RENDERER_MAP entry; no new renderer/config/parser needed. Added to the shared render-parity matrices (config-parity, is_content, sampled_mask); token-exact parity against apply_chat_template verified for both enable_thinking polarities across all conversation shapes. --- renderers/base.py | 7 +++++-- tests/conftest.py | 3 +++ tests/test_renderer_config_parity.py | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/renderers/base.py b/renderers/base.py index 304d6cc..f235fea 100644 --- a/renderers/base.py +++ b/renderers/base.py @@ -1064,10 +1064,13 @@ def bridge_to_next_turn(self, *args: Any, **kwargs: Any) -> "RenderedTokens | No # construction to pin a different date. "meta-llama/Llama-3.2-1B-Instruct": "llama-3", "meta-llama/Llama-3.2-3B-Instruct": "llama-3", - # Poolside Laguna. The two checkpoints ship different chat templates, - # each mirrored by its own renderer class. + # Poolside Laguna. XS.2 and XS-2.1 ship different chat templates, each + # mirrored by its own renderer class. S-2.1 is a larger sibling of XS-2.1 + # with a byte-identical chat template and tokenizer, so it reuses the + # XS-2.1 renderer. "poolside/Laguna-XS.2": "laguna-xs.2", "poolside/Laguna-XS-2.1": "laguna-xs-2.1", + "poolside/Laguna-S-2.1": "laguna-xs-2.1", # GPT-OSS. "openai/gpt-oss-20b": "gpt-oss", "openai/gpt-oss-120b": "gpt-oss", diff --git a/tests/conftest.py b/tests/conftest.py index bd7672e..9fed2db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,9 @@ # XS-2.1 resolves to `LagunaXS21Renderer` via the model name — its # chat template differs from XS.2's (see renderers/laguna_xs2.py). ("poolside/Laguna-XS-2.1", "auto"), + # S-2.1 is a larger sibling of XS-2.1 with a byte-identical chat template + # and tokenizer, so it reuses the XS-2.1 renderer. + ("poolside/Laguna-S-2.1", "auto"), # DeepSeek-V3/R1 are intentionally NOT in this shared barrage: their # chat templates can't render the barrage's tool-call fixtures (the # templates require ``tool['type']`` and a string-serialized diff --git a/tests/test_renderer_config_parity.py b/tests/test_renderer_config_parity.py index 21e7c2a..ddd5a04 100644 --- a/tests/test_renderer_config_parity.py +++ b/tests/test_renderer_config_parity.py @@ -65,6 +65,7 @@ ("nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16", "auto"), ("poolside/Laguna-XS.2", "auto"), ("poolside/Laguna-XS-2.1", "auto"), + ("poolside/Laguna-S-2.1", "auto"), ("tencent/Hy3", "auto"), ("openai/gpt-oss-20b", "gpt-oss"), ] From 058218d62f4ca7fd5c4bcccfb5d0fe9199ed09e1 Mon Sep 17 00:00:00 2001 From: eexwhyzee <13672394+eexwhyzee@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:32:11 -0700 Subject: [PATCH 2/2] fix(laguna): give Laguna-S-2.1 its own renderer for its distinct template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial routing (cdc158e) assumed S-2.1 shipped a byte-identical chat template to XS-2.1 and reused LagunaXS21Renderer. It doesn't: S-2.1 shares XS-2.1's tokenizer (same vocab/merges/special tokens) but its template defaults enable_thinking to True (XS-2.1 defaults False) and adds a preserve_thinking kwarg, widening the reasoning-display gate to `enable_thinking or preserve_thinking`. Reusing the XS-2.1 renderer mismatched apply_chat_template on the default flag (bare `` vs empty `` wrapper), failing test_render_ids / test_build_helpers. The config-parity barrage missed this because it always sets enable_thinking explicitly, never exercising the differing default. Add LagunaS21RendererConfig (enable_thinking=True, preserve_thinking=False) and LagunaS21Renderer, a thin LagunaXS21Renderer subclass that overrides only the reasoning-display gate. Route poolside/Laguna-S-2.1 to it. The token format is otherwise identical, so everything else is inherited. Verified token-exact against apply_chat_template for all enable_thinking × preserve_thinking combinations; added tests/test_laguna_s21.py for the S-2.1-specific behaviours the shared barrage can't reach. Co-Authored-By: Claude Opus 4.8 (1M context) --- renderers/__init__.py | 4 ++ renderers/base.py | 15 +++-- renderers/configs.py | 34 +++++++++++ renderers/laguna_xs2.py | 62 +++++++++++++++++-- tests/conftest.py | 5 +- tests/test_laguna_s21.py | 128 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 tests/test_laguna_s21.py diff --git a/renderers/__init__.py b/renderers/__init__.py index 7f7a75e..626d0cb 100644 --- a/renderers/__init__.py +++ b/renderers/__init__.py @@ -54,6 +54,7 @@ Hy3RendererConfig, KimiK25RendererConfig, KimiK2RendererConfig, + LagunaS21RendererConfig, LagunaXS2RendererConfig, LagunaXS21RendererConfig, Llama3RendererConfig, @@ -90,6 +91,7 @@ "Hy3Renderer": "renderers.hy3", "KimiK25Renderer": "renderers.kimi_k25", "KimiK2Renderer": "renderers.kimi_k2", + "LagunaS21Renderer": "renderers.laguna_xs2", "LagunaXS21Renderer": "renderers.laguna_xs2", "LagunaXS2Renderer": "renderers.laguna_xs2", "Llama3Renderer": "renderers.llama_3", @@ -145,6 +147,8 @@ def __dir__() -> list[str]: "KimiK25RendererConfig", "KimiK2Renderer", "KimiK2RendererConfig", + "LagunaS21Renderer", + "LagunaS21RendererConfig", "LagunaXS2Renderer", "LagunaXS2RendererConfig", "LagunaXS21Renderer", diff --git a/renderers/base.py b/renderers/base.py index f235fea..e922ab3 100644 --- a/renderers/base.py +++ b/renderers/base.py @@ -1066,11 +1066,13 @@ def bridge_to_next_turn(self, *args: Any, **kwargs: Any) -> "RenderedTokens | No "meta-llama/Llama-3.2-3B-Instruct": "llama-3", # Poolside Laguna. XS.2 and XS-2.1 ship different chat templates, each # mirrored by its own renderer class. S-2.1 is a larger sibling of XS-2.1 - # with a byte-identical chat template and tokenizer, so it reuses the - # XS-2.1 renderer. + # that shares its tokenizer but NOT its chat template: S-2.1 defaults + # ``enable_thinking`` to True and adds a ``preserve_thinking`` gate, so it + # gets its own renderer/config (``LagunaS21Renderer``), a thin subclass of + # the XS-2.1 renderer. "poolside/Laguna-XS.2": "laguna-xs.2", "poolside/Laguna-XS-2.1": "laguna-xs-2.1", - "poolside/Laguna-S-2.1": "laguna-xs-2.1", + "poolside/Laguna-S-2.1": "laguna-s-2.1", # GPT-OSS. "openai/gpt-oss-20b": "gpt-oss", "openai/gpt-oss-120b": "gpt-oss", @@ -1318,7 +1320,11 @@ def _populate_registry(): from renderers.hy3 import Hy3Renderer from renderers.kimi_k2 import KimiK2Renderer from renderers.kimi_k25 import KimiK25Renderer - from renderers.laguna_xs2 import LagunaXS2Renderer, LagunaXS21Renderer + from renderers.laguna_xs2 import ( + LagunaS21Renderer, + LagunaXS2Renderer, + LagunaXS21Renderer, + ) from renderers.llama_3 import Llama3Renderer from renderers.minimax_m2 import MiniMaxM2Renderer from renderers.nemotron3 import Nemotron3Renderer, Nemotron3UltraRenderer @@ -1347,6 +1353,7 @@ def _populate_registry(): "kimi-k2.5": KimiK25Renderer, "laguna-xs.2": LagunaXS2Renderer, "laguna-xs-2.1": LagunaXS21Renderer, + "laguna-s-2.1": LagunaS21Renderer, "llama-3": Llama3Renderer, "nemotron-3": Nemotron3Renderer, "nemotron-3-ultra": Nemotron3UltraRenderer, diff --git a/renderers/configs.py b/renderers/configs.py index ad32044..6146e10 100644 --- a/renderers/configs.py +++ b/renderers/configs.py @@ -524,6 +524,37 @@ class LagunaXS21RendererConfig(BaseRendererConfig): upstream default.""" +class LagunaS21RendererConfig(BaseRendererConfig): + """Laguna S-2.1 renderer config. + + S-2.1 is a larger sibling of XS-2.1 sharing its tokenizer (same vocab, + special tokens, and merges), but its chat template is *not* byte-identical: + ``enable_thinking`` defaults to ``True`` (XS-2.1 defaults ``False``), and a + new ``preserve_thinking`` kwarg widens the reasoning-display gate to + ``enable_thinking or preserve_thinking``. The token format is otherwise + identical, so this is served by + :class:`renderers.laguna_xs2.LagunaS21Renderer`, a thin subclass of + ``LagunaXS21Renderer`` that only overrides that gate. + """ + + name: Literal["laguna-s-2.1"] = "laguna-s-2.1" + + enable_thinking: bool = True + """When ``True``, the generation prompt ends with ```` and every + assistant turn renders ``{reasoning}``; when ``False``, + turns open with a bare ````. Mirrors the template's + ``enable_thinking`` kwarg — note S-2.1's upstream default is ``True``, + unlike XS-2.1's ``False``.""" + + preserve_thinking: bool = False + """When ``True``, assistant turns keep their ``{reasoning}`` + block even while ``enable_thinking`` is ``False`` — the template gates + reasoning display on ``enable_thinking or preserve_thinking``. With the + default ``False`` the gate collapses to ``enable_thinking`` and the + renderer matches XS-2.1 turn-for-turn. Mirrors the template's + ``preserve_thinking`` kwarg and its upstream default.""" + + class Llama3RendererConfig(BaseRendererConfig): """Llama-3.x Instruct renderer config. @@ -681,6 +712,7 @@ class DeepSeekR1RendererConfig(BaseRendererConfig): KimiK25RendererConfig, LagunaXS2RendererConfig, LagunaXS21RendererConfig, + LagunaS21RendererConfig, Llama3RendererConfig, MiniMaxM2RendererConfig, Nemotron3RendererConfig, @@ -721,6 +753,7 @@ class DeepSeekR1RendererConfig(BaseRendererConfig): "kimi-k2.5": KimiK25RendererConfig, "laguna-xs.2": LagunaXS2RendererConfig, "laguna-xs-2.1": LagunaXS21RendererConfig, + "laguna-s-2.1": LagunaS21RendererConfig, "llama-3": Llama3RendererConfig, "minimax-m2": MiniMaxM2RendererConfig, "nemotron-3": Nemotron3RendererConfig, @@ -767,6 +800,7 @@ def config_from_name(name: str) -> BaseRendererConfig | None: "Hy3RendererConfig", "KimiK25RendererConfig", "KimiK2RendererConfig", + "LagunaS21RendererConfig", "LagunaXS2RendererConfig", "LagunaXS21RendererConfig", "Llama3RendererConfig", diff --git a/renderers/laguna_xs2.py b/renderers/laguna_xs2.py index bd174f4..b3ec3b6 100644 --- a/renderers/laguna_xs2.py +++ b/renderers/laguna_xs2.py @@ -36,6 +36,13 @@ section ends at ````. - The ```` block is emitted whenever there is system content, tools, or ``enable_thinking`` — even if that leaves it empty. + +S-2.1 is a larger sibling of XS-2.1 served by :class:`LagunaS21Renderer`. It +shares XS-2.1's tokenizer and token format, so the two differ only in the chat +template's thinking knobs: ``enable_thinking`` defaults to ``True`` (XS-2.1 +defaults ``False``), and a new ``preserve_thinking`` kwarg widens the +reasoning-display gate to ``enable_thinking or preserve_thinking``. The +subclass overrides only that gate; everything else is inherited. """ from __future__ import annotations @@ -56,7 +63,11 @@ resolve_thinking_retention, should_rerender_for_thinking_retention, ) -from renderers.configs import LagunaXS2RendererConfig, LagunaXS21RendererConfig +from renderers.configs import ( + LagunaS21RendererConfig, + LagunaXS2RendererConfig, + LagunaXS21RendererConfig, +) from renderers.parsing import parse_laguna_xs2 _DEFAULT_SYSTEM_MESSAGE = ( @@ -107,7 +118,10 @@ class LagunaXS2Renderer: def __init__( self, tokenizer: PreTrainedTokenizer, - config: LagunaXS2RendererConfig | LagunaXS21RendererConfig | None = None, + config: LagunaXS2RendererConfig + | LagunaXS21RendererConfig + | LagunaS21RendererConfig + | None = None, ): self._tokenizer = tokenizer self.config = config or LagunaXS2RendererConfig() @@ -633,10 +647,17 @@ class LagunaXS21Renderer(LagunaXS2Renderer): def __init__( self, tokenizer: PreTrainedTokenizer, - config: LagunaXS21RendererConfig | None = None, + config: LagunaXS21RendererConfig | LagunaS21RendererConfig | None = None, ): super().__init__(tokenizer, config or LagunaXS21RendererConfig()) + def _render_history_reasoning(self) -> bool: + """Whether an assistant turn renders its ``{reasoning}`` + block (vs opening with a bare ````). Mirrors the template's + reasoning-display gate; XS-2.1 gates this on ``enable_thinking`` alone. + """ + return self.config.enable_thinking + def render( self, messages: list[Message], @@ -920,7 +941,7 @@ def _render_assistant( # scaffolding the model never samples. emit_special(self._assistant, msg_idx, is_sampled=False, is_content=False) - if self.config.enable_thinking: + if self._render_history_reasoning(): # ``{reasoning}`` renders verbatim, even when # the reasoning is empty; the opener is the gen-prompt # prefill, the rest the model sampled. @@ -962,3 +983,36 @@ def _render_assistant( emit_special(self._assistant_end, msg_idx, is_sampled=True, is_content=True) emit_text("\n", msg_idx, is_sampled=False, is_content=False) + + +class LagunaS21Renderer(LagunaXS21Renderer): + """Laguna-S-2.1 — a larger sibling of XS-2.1 that shares its tokenizer + and token format but ships a slightly different chat template. + + The only template differences are the ``enable_thinking`` default (``True`` + here, ``False`` for XS-2.1) and a new ``preserve_thinking`` kwarg that + widens the reasoning-display gate to ``enable_thinking or + preserve_thinking``. Everything else — role tags, tool-call packing, + system-block gate, generation prompt, and the whole parse skeleton — is + inherited unchanged from :class:`LagunaXS21Renderer`. + """ + + # Narrow the inherited ``config`` attribute so ``preserve_thinking`` (an + # S-2.1-only field) resolves; ``__init__`` always stores an S-2.1 config. + config: LagunaS21RendererConfig + + def __init__( + self, + tokenizer: PreTrainedTokenizer, + config: LagunaS21RendererConfig | None = None, + ): + # ``LagunaXS21Renderer.__init__`` forwards any config straight through; + # pass an S-2.1 config so ``enable_thinking`` defaults to ``True`` and + # ``preserve_thinking`` is available. + super().__init__(tokenizer, config or LagunaS21RendererConfig()) + + def _render_history_reasoning(self) -> bool: + # S-2.1's template gates reasoning display on + # ``enable_thinking or preserve_thinking`` — the latter keeps + # historical ```` blocks even while thinking is off. + return self.config.enable_thinking or self.config.preserve_thinking diff --git a/tests/conftest.py b/tests/conftest.py index 9fed2db..79f81c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,8 +40,9 @@ # XS-2.1 resolves to `LagunaXS21Renderer` via the model name — its # chat template differs from XS.2's (see renderers/laguna_xs2.py). ("poolside/Laguna-XS-2.1", "auto"), - # S-2.1 is a larger sibling of XS-2.1 with a byte-identical chat template - # and tokenizer, so it reuses the XS-2.1 renderer. + # S-2.1 is a larger sibling of XS-2.1 sharing its tokenizer but NOT its + # chat template (S-2.1 defaults enable_thinking=True and adds a + # preserve_thinking gate), so it resolves to LagunaS21Renderer. ("poolside/Laguna-S-2.1", "auto"), # DeepSeek-V3/R1 are intentionally NOT in this shared barrage: their # chat templates can't render the barrage's tool-call fixtures (the diff --git a/tests/test_laguna_s21.py b/tests/test_laguna_s21.py new file mode 100644 index 0000000..a5968ec --- /dev/null +++ b/tests/test_laguna_s21.py @@ -0,0 +1,128 @@ +"""Laguna-S-2.1 focused tests. + +S-2.1 shares XS-2.1's tokenizer and token format, so the shared matrices +(conftest / config-parity) already assert byte parity on the common shapes +via :class:`LagunaS21Renderer`. This file pins the two behaviours that make +S-2.1 *differ* from XS-2.1 — and that the shared barrage can't reach because +it never sets the relevant kwargs: + +- ``enable_thinking`` defaults to ``True`` (XS-2.1 defaults ``False``), so the + auto-resolved renderer renders reasoning by default, and +- the new ``preserve_thinking`` kwarg keeps historical ```` blocks even + while ``enable_thinking`` is ``False`` — the template gates reasoning display + on ``enable_thinking or preserve_thinking``. +""" + +from __future__ import annotations + +from functools import lru_cache +from itertools import product + +from renderers import create_renderer +from renderers.base import load_tokenizer +from renderers.configs import LagunaS21RendererConfig +from renderers.laguna_xs2 import LagunaS21Renderer + +_MODEL = "poolside/Laguna-S-2.1" + + +@lru_cache(maxsize=None) +def _tok(): + return load_tokenizer(_MODEL) + + +def _renderer(**config_kwargs) -> LagunaS21Renderer: + renderer = create_renderer(_tok(), LagunaS21RendererConfig(**config_kwargs)) + assert isinstance(renderer, LagunaS21Renderer) + return renderer + + +def _expected(msgs, *, add_generation_prompt=False, **template_kwargs): + return list( + _tok().apply_chat_template( + msgs, + add_generation_prompt=add_generation_prompt, + tokenize=True, + return_dict=False, + **template_kwargs, + ) + ) + + +def test_auto_resolves_to_s21_renderer_with_thinking_on_by_default(): + """``poolside/Laguna-S-2.1`` resolves to :class:`LagunaS21Renderer`, and + its config defaults ``enable_thinking=True`` / ``preserve_thinking=False`` + — matching S-2.1's template defaults (unlike XS-2.1's thinking-off).""" + r = create_renderer(_tok()) # auto-resolve via MODEL_RENDERER_MAP + assert isinstance(r, LagunaS21Renderer) + assert r.config.name == "laguna-s-2.1" + assert r.config.enable_thinking is True + assert r.config.preserve_thinking is False + + +def test_default_renders_empty_think_wrapper(): + """With thinking on by default, a reasoning-free assistant turn still + opens with the empty ```` wrapper (this is exactly the + render_ids divergence from the XS-2.1 renderer).""" + msgs = [ + {"role": "user", "content": "What is 2+2?"}, + {"role": "assistant", "content": "4"}, + ] + r = _renderer() # defaults: enable_thinking=True + ours = r.render_ids(msgs) + assert ours == _expected(msgs) + assert "4" in _tok().decode(ours) + + +def test_preserve_thinking_keeps_history_when_thinking_off(): + """``enable_thinking=False`` alone drops reasoning, but with + ``preserve_thinking=True`` the historical ``{reasoning}`` + survives — matching the template's ``enable_thinking or preserve_thinking`` + gate.""" + msgs = [ + {"role": "user", "content": "What is 2+2?"}, + {"role": "assistant", "reasoning_content": "Simple arithmetic", "content": "4"}, + ] + r = _renderer(enable_thinking=False, preserve_thinking=True) + ours = r.render_ids(msgs) + assert ours == _expected(msgs, enable_thinking=False, preserve_thinking=True) + assert "Simple arithmetic" in _tok().decode(ours) + + +def test_no_preserve_thinking_drops_history_when_thinking_off(): + """With both flags off the gate collapses to ``enable_thinking`` and the + turn opens with a bare ````, dropping the reasoning — identical to + the XS-2.1 renderer's default.""" + msgs = [ + {"role": "user", "content": "What is 2+2?"}, + {"role": "assistant", "reasoning_content": "Simple arithmetic", "content": "4"}, + ] + r = _renderer(enable_thinking=False, preserve_thinking=False) + ours = r.render_ids(msgs) + assert ours == _expected(msgs, enable_thinking=False, preserve_thinking=False) + assert "Simple arithmetic" not in _tok().decode(ours) + + +def test_all_thinking_flag_combos_match_template(): + """Byte parity against ``apply_chat_template`` for every + ``enable_thinking`` × ``preserve_thinking`` combination, over a multi-turn + conversation carrying historical reasoning.""" + msgs = [ + {"role": "user", "content": "Q1"}, + {"role": "assistant", "reasoning_content": "deduce", "content": "A1"}, + {"role": "user", "content": "Q2"}, + {"role": "assistant", "reasoning_content": "more", "content": "A2"}, + ] + for enable_thinking, preserve_thinking in product((False, True), repeat=2): + r = _renderer( + enable_thinking=enable_thinking, preserve_thinking=preserve_thinking + ) + for add_generation_prompt in (False, True): + assert r.render_ids( + msgs, add_generation_prompt=add_generation_prompt + ) == _expected( + msgs, + add_generation_prompt=add_generation_prompt, + enable_thinking=enable_thinking, + preserve_thinking=preserve_thinking, + ), (enable_thinking, preserve_thinking, add_generation_prompt)