diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34e81487..29c673cc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ on: - "uv.lock" - ".github/workflows/test.yml" pull_request: - branches: [main] + branches: [main, "codex/**"] paths: - "**.py" - "pyproject.toml" diff --git a/tests/conformance/__init__.py b/tests/conformance/__init__.py new file mode 100644 index 00000000..8169ee34 --- /dev/null +++ b/tests/conformance/__init__.py @@ -0,0 +1 @@ +"""Independent reference tests through the public renderer interface.""" diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py new file mode 100644 index 00000000..110bb4b5 --- /dev/null +++ b/tests/conformance/conftest.py @@ -0,0 +1,14 @@ +"""Release real tokenizer assets between conformance modules.""" + +import gc + +import pytest + +from tests.conformance.harness import tokenizer + + +@pytest.fixture(scope="module", autouse=True) +def release_reference_assets(): + yield + tokenizer.cache_clear() + gc.collect() diff --git a/tests/conformance/encoder.py b/tests/conformance/encoder.py new file mode 100644 index 00000000..2766149e --- /dev/null +++ b/tests/conformance/encoder.py @@ -0,0 +1,87 @@ +"""Execute the reviewed, pinned model-supplied Python encoder directly.""" + +import copy +import hashlib +import importlib.util +import json +from functools import cache +from pathlib import Path + +from huggingface_hub import hf_hub_download + +from tests.model_assets import model_revision + +DEEPSEEK_ENCODER = { + "repo": "deepseek-ai/DeepSeek-V4-Flash-0731", + "file": "encoding/encoding_dsv4.py", + "sha256": "abc0d26120250dda0ae077dc64aa28836026e61e970854aaeb792445e6a0dde6", +} + + +@cache +def encoder(): + source = DEEPSEEK_ENCODER + path = hf_hub_download( + source["repo"], + source["file"], + revision=model_revision(source["repo"]), + ) + assert hashlib.sha256(Path(path).read_bytes()).hexdigest() == source["sha256"] + spec = importlib.util.spec_from_file_location("pinned_deepseek_encoder", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def deepseek_reference( + tok, + messages, + *, + tools=None, + add_generation_prompt=False, + enable_thinking=False, + drop_thinking=True, + reasoning_effort="low", +): + upstream = encoder() + messages = copy.deepcopy(messages) + if tools: + target = next((m for m in messages if m["role"] == "developer"), None) + if target is None: + target = next((m for m in messages if m["role"] == "system"), None) + if target is None: + target = {"role": "system", "content": ""} + messages.insert(0, target) + target["tools"] = tools + for message in messages: + for call in message.get("tool_calls") or []: + function = call["function"] + if not isinstance(function["arguments"], str): + function["arguments"] = json.dumps( + function["arguments"], ensure_ascii=False + ) + text = upstream.encode_messages( + messages, + thinking_mode="thinking" if enable_thinking else "chat", + drop_thinking=drop_thinking, + reasoning_effort=reasoning_effort, + ) + # Upstream's API always opens generation after a terminal query. The + # renderer API also exposes a completed-transcript view. Remove only that + # terminal generation prefill; all internal transitions remain upstream's. + if not add_generation_prompt: + merged = upstream.merge_tool_messages(messages) + if ( + merged + and merged[-1]["role"] in {"user", "developer"} + and not merged[-1].get("task") + ): + suffix = upstream.ASSISTANT_SP_TOKEN + ( + upstream.thinking_start_token + if enable_thinking + else upstream.thinking_end_token + ) + assert text.endswith(suffix) + text = text[: -len(suffix)] + return tok.encode(text, add_special_tokens=False) diff --git a/tests/conformance/harness.py b/tests/conformance/harness.py new file mode 100644 index 00000000..d61eb659 --- /dev/null +++ b/tests/conformance/harness.py @@ -0,0 +1,102 @@ +"""No renderer internals: official encoder → token backend → public client.""" + +from __future__ import annotations + +import json +from functools import lru_cache +from typing import Any + +import httpx + +from renderers import config_from_name, create_renderer +from tests.conformance.encoder import deepseek_reference +from tests.model_assets import load_test_tokenizer +from tests.parity import ModelCase +from tests.reference_rendering import render_reference + + +@lru_cache(maxsize=1) +def tokenizer(model: str): + return load_test_tokenizer(model) + + +def renderer(case: ModelCase, kwargs: dict[str, Any]): + return create_renderer( + tokenizer(case.model), + config_from_name(case.resolved_renderer), + chat_template_kwargs=kwargs or None, + ) + + +def reference(case: ModelCase, messages: list, kwargs: dict, **render_kwargs): + options = dict(case.oracle_defaults) | kwargs + options = {key: value for key, value in options.items() if value is not None} + if case.oracle == "deepseek-v4": + return deepseek_reference( + tokenizer(case.model), messages, **options, **render_kwargs + ) + return render_reference( + tokenizer(case.model), messages, oracle=case.oracle, **options, **render_kwargs + ) + + +def assert_tokens(actual: list[int], expected: list[int], case: ModelCase): + if actual == expected: + return + first = next( + (i for i, (a, b) in enumerate(zip(actual, expected)) if a != b), + min(len(actual), len(expected)), + ) + tok = tokenizer(case.model) + window = slice(max(0, first - 12), first + 24) + raise AssertionError( + f"{case.model}: first differing token {first}; " + f"lengths native={len(actual)}, reference={len(expected)}\n" + f"native: {tok.decode(actual[window], skip_special_tokens=False)!r}\n" + f"reference: {tok.decode(expected[window], skip_special_tokens=False)!r}" + ) + + +def assert_attribution(rendered): + size = len(rendered.token_ids) + for field in ("message_indices",): + assert len(getattr(rendered, field)) == size, field + assert len(rendered.sampled_mask) in {0, size} + assert len(rendered.is_content) in {0, size} + + +class TokenBackend: + """Script sampling only. Rendering and parsing remain the real implementation.""" + + base_url = "http://conformance.invalid/v1" + + def __init__(self, prompt: list[int], completion: list[int], finish="stop"): + self.prompt = prompt + self.completion = completion + self.finish = finish + self.requests: list[dict] = [] + + async def post(self, endpoint, *, body, cast_to, **kwargs): + assert endpoint == "http://conformance.invalid/inference/v1/generate" + assert body["token_ids"] == self.prompt + assert body["sampling_params"]["skip_special_tokens"] is False + self.requests.append(body) + return httpx.Response( + 200, + content=json.dumps( + { + "choices": [ + { + "token_ids": self.completion, + "finish_reason": self.finish, + "logprobs": { + "content": [ + {"token": f"token_id:{t}", "logprob": -0.5} + for t in self.completion + ] + }, + } + ] + } + ).encode(), + ) diff --git a/tests/conformance/test_rollouts.py b/tests/conformance/test_rollouts.py new file mode 100644 index 00000000..3470af14 --- /dev/null +++ b/tests/conformance/test_rollouts.py @@ -0,0 +1,267 @@ +"""Production client + actual generation boundary + three successive turns.""" + +import asyncio + +import numpy as np +import pytest + +from renderers.client import generate +from tests.conformance.harness import ( + TokenBackend, + assert_attribution, + assert_tokens, + reference, + renderer, + tokenizer, +) +from tests.parity import MODEL_CATALOG, kwarg_combinations + +pytestmark = pytest.mark.model + + +def matrix(): + for case in MODEL_CATALOG: + for kwargs in kwarg_combinations(case): + # These knobs accept pre-serialized assistant bodies, rather than + # the structured assistant messages returned by generate(). Their + # complete matrix is exercised by test_finished_trace. + if ( + kwargs.get("is_training") + or kwargs.get("render_assistant_messages_raw") + or kwargs.get("raw_last_assistant") + ): + continue + yield pytest.param(case, kwargs, id=f"{case.model}/{kwargs!r}") + + +def canonical_turn(case, kwargs, native, history): + prompt = reference(case, history, kwargs, add_generation_prompt=True) + # Some templates require an open reasoning channel, others prefill a closed + # channel. Select a message representable at that exact reference boundary. + # The generation prompt is NEVER replaced with a historical-message prefix. + for assistant in ( + {"role": "assistant", "content": "Four.", "reasoning_content": "Compute."}, + {"role": "assistant", "content": "Four."}, + ): + finished = reference(case, history + [assistant], kwargs) + if finished[: len(prompt)] == prompt: + break + else: + if case.resolved_renderer == "deepseek-r1": + # R1 deliberately removes ALL historical reasoning, so historical + # serialization cannot produce a live completion. This is its + # official prefill + vLLM's deepseek_r1 output grammar. + completion = tokenizer(case.model).encode( + "Compute.Four.<|end▁of▁sentence|>", add_special_tokens=False + ) + return prompt, completion, assistant, "Compute.", "Four." + if case.resolved_renderer == "gpt-oss": + # Harmony's live prefix is analysis; a finished content-only + # history is final. Use the official Harmony channel transition. + completion = tokenizer(case.model).encode( + "Compute.<|end|><|start|>assistant<|channel|>final<|message|>Four.<|return|>", + add_special_tokens=False, + ) + return prompt, completion, assistant, "Compute.", "Four." + closed_channels = ("", "", "") + if ( + tokenizer(case.model) + .decode(prompt, skip_special_tokens=False) + .rstrip() + .endswith(closed_channels) + ): + assistant = {"role": "assistant", "content": "Four."} + finished = reference(case, history + [assistant], kwargs) + text = tokenizer(case.model).decode(finished, skip_special_tokens=False) + body = text[text.rindex("Four.") :] + completion = tokenizer(case.model).encode(body, add_special_tokens=False) + return finish_turn( + case, + kwargs, + native, + history, + prompt, + finished, + completion, + assistant, + None, + "Four.", + ) + assert_tokens(finished[: len(prompt)], prompt, case) + raise AssertionError( + "No canonical completion at the official generation boundary" + ) + + completion = finished[len(prompt) :] + reason = assistant.get("reasoning_content") + if ( + reference(case, history + [{"role": "assistant", "content": "Four."}], kwargs) + == finished + ): + reason = None # The reference template ignores this metadata field. + content = "Four." + if case.resolved_renderer == "glm-4.5": + reason = "\n" + reason if reason else None + content = "\n" + content + return finish_turn( + case, + kwargs, + native, + history, + prompt, + finished, + completion, + assistant, + reason, + content, + ) + + +def finish_turn( + case, + kwargs, + native, + history, + prompt, + finished, + completion, + assistant, + reason, + content, +): + stops = set(native.get_stop_token_ids()) + end = next( + (i + 1 for i in range(len(completion) - 1, -1, -1) if completion[i] in stops), + None, + ) + if end is not None: + completion = completion[:end] + else: + extended = reference( + case, history + [assistant, {"role": "user", "content": "Next."}], kwargs + ) + # A new query may drop historical reasoning, so its prefix need not + # equal the live turn. GLM samples the next user role as its stop. + stop = max(i for i, token in enumerate(extended) if token in stops) + completion += extended[stop : stop + 1] + return prompt, completion, assistant, reason, content + + +def expected_extension(case, kwargs, history, assistant, new, completion, native): + following = reference( + case, history + [assistant] + new, kwargs, add_generation_prompt=True + ) + # Perturb only the previous assistant body. The common suffix locates the + # actual turn-close boundary IN THE FOLLOWING REFERENCE RENDER, even when + # the template drops historical reasoning upon arrival of a new query. + probe = dict(assistant, content="BOUNDARY_PROBE_endXYZ") + other = reference(case, history + [probe] + new, kwargs, add_generation_prompt=True) + common = 0 + while ( + common < min(len(following), len(other)) + and following[-common - 1] == other[-common - 1] + ): + common += 1 + suffix = following[len(following) - common :] + stops = set(native.get_stop_token_ids()) + if case.resolved_renderer == "gpt-oss": + # Harmony history closes a message with end, whereas sampled final + # answers close the turn with return. The reference owns both forms. + stops.add(tokenizer(case.model).convert_tokens_to_ids("<|end|>")) + stop = next(i for i, token in enumerate(suffix) if token in stops) + return suffix[stop + 1 :] + + +@pytest.mark.parametrize("case,kwargs", tuple(matrix())) +def test_three_turn_client_rollout(case, kwargs): + native = renderer(case, kwargs) + history = [{"role": "user", "content": "What is two plus two?"}] + next_prompt = None + for turn in range(3): + prompt, completion, assistant, reasoning, content = canonical_turn( + case, kwargs, native, history + ) + if next_prompt is not None: + prompt = next_prompt.token_ids + backend = TokenBackend(prompt, completion) + result = asyncio.run( + generate( + client=backend, + renderer=native, + messages=history, + model=case.model, + prompt_ids=next_prompt.token_ids if next_prompt is not None else None, + prompt_attribution=next_prompt, + max_prompt_len=1_000_000, + ) + ) + assert result["prompt_ids"] == prompt + assert result["completion_ids"] == completion + np.testing.assert_array_equal( + result["completion_logprobs"], [-0.5] * len(completion) + ) + assert result["content"] == content + # Empty channel versus absent channel has the same OpenAI projection; + # do not normalize ANY nonempty reasoning or content whitespace. + assert (result["reasoning_content"] or None) == reasoning + assert result["reasoning_complete"] is ( + None if case.resolved_renderer == "default" else True + ) + assert not result["tool_calls"] + assert len(backend.requests) == 1 + assert_attribution(result["prompt_attribution"]) + # Reuse the PUBLIC parser result, just as a real conversation does. + # Reusing the fixture message here would hide parser/template drift. + assistant = {"role": "assistant", "content": result["content"]} + if result["reasoning_content"] is not None: + assistant["reasoning_content"] = result["reasoning_content"] + new = [{"role": "user", "content": f"Next query {turn}."}] + bridge = native.bridge_to_next_turn(prompt, completion, new) + extension = expected_extension( + case, kwargs, history, assistant, new, completion, native + ) + history += [assistant] + new + expected = reference(case, history, kwargs, add_generation_prompt=True) + if bridge is not None: + # For this canonical stream no serialization normalization is + # needed. Both prefix preservation AND complete template parity + # must hold, not just a plausible-looking decoded suffix. + assert ( + bridge.token_ids[: len(prompt) + len(completion)] == prompt + completion + ) + assert_tokens(bridge.token_ids, prompt + completion + extension, case) + assert_attribution(bridge) + assert not any(bridge.sampled_mask) + next_prompt = bridge + else: + next_prompt = native.render(history, add_generation_prompt=True) + assert_tokens(next_prompt.token_ids, expected, case) + + +@pytest.mark.parametrize("case,kwargs", tuple(matrix())) +def test_budget_stop_preserves_sampled_prefix(case, kwargs): + native = renderer(case, kwargs) + history = [{"role": "user", "content": "What is two plus two?"}] + prompt, completed, _, _, _ = canonical_turn(case, kwargs, native, history) + sampled = completed[: max(1, len(completed) // 2)] + backend = TokenBackend(prompt, sampled, finish="length") + result = asyncio.run( + generate( + client=backend, + renderer=native, + messages=history, + model=case.model, + max_prompt_len=1_000_000, + ) + ) + assert result["prompt_ids"] == prompt + assert result["completion_ids"] == sampled + np.testing.assert_array_equal(result["completion_logprobs"], [-0.5] * len(sampled)) + assert result["finish_reason"] == "length" + bridge = native.bridge_to_next_turn( + prompt, sampled, [{"role": "user", "content": "Continue."}] + ) + if bridge is not None: + assert bridge.token_ids[: len(prompt) + len(sampled)] == prompt + sampled + assert_attribution(bridge) + assert not any(bridge.sampled_mask) diff --git a/tests/conformance/test_tool_cycles.py b/tests/conformance/test_tool_cycles.py new file mode 100644 index 00000000..07494ea8 --- /dev/null +++ b/tests/conformance/test_tool_cycles.py @@ -0,0 +1,213 @@ +"""Two tool cycles through the production client and exact reference bridges.""" + +import asyncio + +import numpy as np +import pytest + +from renderers import config_from_name, create_renderer +from renderers.base import ToolCallParseStatus +from renderers.client import generate +from tests.conformance.harness import ( + TokenBackend, + assert_attribution, + assert_tokens, + reference, + renderer, + tokenizer, +) +from tests.parity import MODEL_CATALOG, TOOLS + +pytestmark = pytest.mark.model + + +def cases(): + for case in MODEL_CATALOG: + if "full-tool-cycle" not in case.excluded_scenarios: + yield pytest.param(case, id=case.model) + + +def tool_renderer(case, kwargs): + if case.resolved_renderer == "default": + return create_renderer( + tokenizer(case.model), + config_from_name("default").model_copy(update={"tool_parser": "qwen3"}), + ) + return renderer(case, kwargs) + + +def tool_turn(case, turn, city): + call_id = ( + f"functions.get_weather:{turn}" + if case.resolved_renderer in {"kimi-k2", "kimi-k2.5"} + else f"call_{turn}" + ) + assistant = { + "role": "assistant", + "content": "", + "reasoning_content": "Check weather.", + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": {"name": "get_weather", "arguments": {"city": city}}, + } + ], + } + new = [ + { + "role": "tool", + "name": "get_weather", + "tool_call_id": call_id, + "content": "Sunny.", + } + ] + return assistant, new + + +def reference_turn(case, native, history, assistant, new, kwargs): + prompt = reference(case, history, kwargs, tools=TOOLS, add_generation_prompt=True) + finished = reference(case, history + [assistant], kwargs, tools=TOOLS) + if finished[: len(prompt)] == prompt: + completion = finished[len(prompt) :] + else: + # A closed live prefill may be absent in history. Locate the start of + # the tool output by comparing two official completed serializations. + assistant = dict(assistant) + assistant.pop("reasoning_content", None) + finished = reference(case, history + [assistant], kwargs, tools=TOOLS) + empty = reference( + case, history + [{"role": "assistant", "content": ""}], kwargs, tools=TOOLS + ) + start = next( + (i for i, (a, b) in enumerate(zip(finished, empty)) if a != b), + min(len(finished), len(empty)), + ) + completion = finished[start:] + stops = set(native.get_stop_token_ids()) + end = next( + (i + 1 for i in range(len(completion) - 1, -1, -1) if completion[i] in stops), + None, + ) + if end is not None: + completion = completion[:end] + else: + extended = reference(case, history + [assistant] + new, kwargs, tools=TOOLS) + stop = max(i for i, token in enumerate(extended) if token in stops) + completion += extended[stop : stop + 1] + return prompt, completion, assistant + + +def tool_extension(case, native, history, assistant, new, kwargs): + following = reference( + case, + history + [assistant] + new, + kwargs, + tools=TOOLS, + add_generation_prompt=True, + ) + probe = dict( + assistant, + tool_calls=[ + { + "id": "call_probe", + "type": "function", + "function": { + "name": "get_weather", + "arguments": {"city": "BOUNDARY_PROBE_endXYZ"}, + }, + } + ], + ) + other = reference( + case, history + [probe] + new, kwargs, tools=TOOLS, add_generation_prompt=True + ) + common = 0 + while ( + common < min(len(following), len(other)) + and following[-common - 1] == other[-common - 1] + ): + common += 1 + suffix = following[len(following) - common :] + stops = set(native.get_stop_token_ids()) + stop = next(i for i, token in enumerate(suffix) if token in stops) + return suffix[stop + 1 :] + + +@pytest.mark.parametrize("case", tuple(cases())) +@pytest.mark.parametrize("city", ["Paris", "", " Paris\nMünchen ☁ "]) +def test_two_tool_cycles(case, city): + kwargs = {"is_training": False} if case.resolved_renderer == "hy3" else {} + native = tool_renderer(case, kwargs) + history = [ + {"role": "system", "content": "Use the weather tool."}, + {"role": "user", "content": "Weather?"}, + ] + next_prompt = None + for turn in range(2): + assistant, new = tool_turn(case, turn, city) + prompt, completion, assistant = reference_turn( + case, native, history, assistant, new, kwargs + ) + if next_prompt is not None: + prompt = next_prompt.token_ids + backend = TokenBackend(prompt, completion) + result = asyncio.run( + generate( + client=backend, + renderer=native, + messages=history, + model=case.model, + tools=TOOLS, + max_prompt_len=1_000_000, + prompt_ids=next_prompt.token_ids if next_prompt is not None else None, + prompt_attribution=next_prompt, + ) + ) + assert result["prompt_ids"] == prompt + assert result["completion_ids"] == completion + np.testing.assert_array_equal( + result["completion_logprobs"], [-0.5] * len(completion) + ) + assert len(result["tool_calls"]) == 1 + call = result["tool_calls"][0] + assert call.status == ToolCallParseStatus.OK + assert call.name == "get_weather" + assert call.arguments == {"city": city} + assert result["finish_reason"] == "tool_calls" + assistant = { + "role": "assistant", + "content": result["content"], + "reasoning_content": result["reasoning_content"], + "tool_calls": [ + { + "id": call.id or new[0]["tool_call_id"], + "type": "function", + "function": {"name": call.name, "arguments": call.arguments}, + } + ], + } + new[0]["tool_call_id"] = assistant["tool_calls"][0]["id"] + bridge = native.bridge_to_next_turn(prompt, completion, new, tools=TOOLS) + if bridge is not None: + extension = tool_extension(case, native, history, assistant, new, kwargs) + assert_tokens(bridge.token_ids, prompt + completion + extension, case) + assert_attribution(bridge) + assert not any(bridge.sampled_mask) + next_prompt = bridge + else: + next_prompt = native.render( + history + [assistant] + new, tools=TOOLS, add_generation_prompt=True + ) + assert_tokens( + next_prompt.token_ids, + reference( + case, + history + [assistant] + new, + kwargs, + tools=TOOLS, + add_generation_prompt=True, + ), + case, + ) + history += [assistant] + new diff --git a/tests/conformance/test_traces.py b/tests/conformance/test_traces.py new file mode 100644 index 00000000..c92e80e5 --- /dev/null +++ b/tests/conformance/test_traces.py @@ -0,0 +1,37 @@ +"""Reference coverage and the exact supported generation profiles.""" + +from typing import get_args + +import pytest + +from renderers.configs import RendererConfig +from tests.conformance.harness import ( + assert_tokens, + reference, + renderer, +) +from tests.conformance.test_rollouts import matrix as inference_matrix +from tests.model_assets import MODEL_REVISIONS +from tests.parity import MODEL_CATALOG + +pytestmark = pytest.mark.model + + +def test_every_renderer_has_a_reference_checkpoint(): + union = get_args(RendererConfig)[0] + declared = {cls.model_fields["name"].default for cls in get_args(union)} + # Factory choices are not wire formats. + assert declared - {"auto", "custom"} == { + case.resolved_renderer for case in MODEL_CATALOG + } + assert {case.model for case in MODEL_CATALOG} <= MODEL_REVISIONS.keys() + + +@pytest.mark.parametrize("case,kwargs", tuple(inference_matrix())) +def test_generation_prefill_matches_reference_profile(case, kwargs): + messages = [{"role": "user", "content": "Test."}] + assert_tokens( + renderer(case, kwargs).render_ids(messages, add_generation_prompt=True), + reference(case, messages, kwargs, add_generation_prompt=True), + case, + ) diff --git a/tests/test_parity.py b/tests/test_parity.py index 928934bd..6a9aed2d 100644 --- a/tests/test_parity.py +++ b/tests/test_parity.py @@ -29,6 +29,8 @@ from renderers import create_renderer from renderers.base import MODEL_RENDERER_MAP from renderers.configs import RendererConfig, _config_class_for +from tests.conformance.harness import assert_attribution, reference +from tests.conformance.harness import tokenizer as reference_tokenizer from tests.model_assets import load_test_tokenizer as load_tokenizer from tests.reference_rendering import ( DEFAULT_REFERENCE_ORACLE, @@ -36,7 +38,6 @@ RENDERER_ORACLE_ROUTES, reference_oracle_for_model, reference_oracle_for_renderer, - render_reference, ) pytestmark = pytest.mark.model @@ -199,26 +200,16 @@ def test_renderer_matches_reference( scenario: Scenario, kwargs: Mapping[str, Any], ): - tokenizer = _tokenizer(case.model) renderer = _renderer(case.model, case.renderer, tuple(kwargs.items())) for key, value in kwargs.items(): if value is not None: assert getattr(renderer.config, key) == value - oracle_kwargs = dict(case.oracle_defaults) - oracle_kwargs.update(kwargs) - # Config ``None`` values mean "defer to the oracle default"; omission - # expresses the same state for every registered adapter. - oracle_kwargs = { - key: value for key, value in oracle_kwargs.items() if value is not None - } - expected = render_reference( - tokenizer, - [dict(message) for message in scenario.messages], - oracle=case.oracle, - **oracle_kwargs, - **scenario.render_kwargs, - ) + messages = [dict(message) for message in scenario.messages] + expected = reference(case, messages, dict(kwargs), **scenario.render_kwargs) + rendered = renderer.render(messages, **scenario.render_kwargs) + assert rendered.token_ids == expected + assert_attribution(rendered) got = renderer.render_ids(list(scenario.messages), **scenario.render_kwargs) assert got == expected, ( f"{case.model} / {scenario.id} / {dict(kwargs)!r}: renderer diverged " @@ -231,3 +222,4 @@ def teardown_module(): """Release model resources when this module finishes, not at process exit.""" _tokenizer.cache_clear() _renderer.cache_clear() + reference_tokenizer.cache_clear()