Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/art/tinker/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
from art.tokenizer import get_tokenizer
from art.types import Message, Tools
from art.utils.append_only import (
chat_prefix_eligible,
chat_prefix_observations,
chat_prefix_scope,
has_renderable_tool_arguments,
output_prefix_observations,
preserves_history,
Expand Down Expand Up @@ -378,9 +380,11 @@ async def chat_completions(
samplable_model = await tenant.get_samplable_model(body["model"])
template_kwargs = cast(dict[str, Any], body).get("chat_template_kwargs")
preserve = preserves_history(template_kwargs)
scope = json.dumps(
[id(tenant), samplable_model.base_model, template_kwargs],
sort_keys=True,
scope = chat_prefix_scope(
json.dumps(
[id(tenant), samplable_model.base_model, template_kwargs],
sort_keys=True,
)
)
rendered_prompt_tokens = await worker.prompt_tokens(
base_model=samplable_model.base_model,
Expand Down Expand Up @@ -432,6 +436,7 @@ async def chat_completions(
) = await worker.chat_completion_and_prefixes(
base_model=samplable_model.base_model,
sample_response=sample_response,
parallel_tool_calls=body.get("parallel_tool_calls"),
model_name=body["model"],
prompt_tokens=prompt_tokens,
rendered_prompt=rendered_prompt_tokens,
Expand Down Expand Up @@ -633,6 +638,7 @@ async def chat_completion_and_prefixes(
messages: list[ChatCompletionMessageParam],
tools: list[ChatCompletionToolUnionParam] | None,
chat_template_kwargs: dict[str, Any] | None = None,
parallel_tool_calls: bool | None = None,
) -> tuple[ChatCompletion, list[tuple[list[int], list[int], tuple[Any, ...]]]]:
renderer = self._get_renderer(base_model)
choices: list[Choice] = []
Expand Down Expand Up @@ -683,7 +689,10 @@ async def render(assistant: dict[str, Any]) -> list[int]:
)
if reasoning
else None,
complete=sequence.stop_reason == "stop",
complete=sequence.stop_reason == "stop"
and chat_prefix_eligible(
parallel_tool_calls, tools, openai_message
),
)
)
tool_calls = (
Expand Down
29 changes: 28 additions & 1 deletion src/art_inference/append_only.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,33 @@

from collections.abc import Awaitable, Callable, Mapping, Sequence
from functools import wraps
import hashlib
import json
import sys
from typing import Any

from .token_prefix import PrefixEdit, prefix_edits

PrefixObservation = tuple[list[int], list[int], tuple[PrefixEdit, ...]]
CHAT_PREFIX_POLICY = "serial-tool-lossless-v1"


def chat_prefix_scope(base_scope: str) -> str:
"""Fence certificates produced before serial-tool projection was checked.

Producers must derive this namespace themselves, not accept a caller's
policy label. Reads, including local and fallback reads, use only this scope.
"""
return hashlib.sha256(
json.dumps([CHAT_PREFIX_POLICY, base_scope], separators=(",", ":")).encode()
).hexdigest()


def chat_prefix_eligible(
parallel_tool_calls: bool | None, tools: Any, message: Mapping[str, Any]
) -> bool:
"""Serial projection has no trusted witness for omitted sampled calls."""
return parallel_tool_calls is not False or not (tools or message.get("tool_calls"))


def _rendering_edits(tokenizer, rendered, raw):
Expand Down Expand Up @@ -500,7 +520,14 @@ async def complete(message: Mapping[str, Any]) -> list[int] | None:
raw_prompt,
output,
reasoning_prompt=reasoning_prompt,
complete=finished,
# Serial tool projection can hide sampled calls even when the
# parsed result contains only one (or no) call. Its terminal
# token does not prove full-turn equivalence; independently
# aligned reasoning remains eligible above.
complete=finished
and chat_prefix_eligible(
payload.get("parallel_tool_calls"), payload.get("tools"), message
),
)
)
return entries
2 changes: 2 additions & 0 deletions src/art_inference/sglang.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ async def record_response(payload):
protocol = importer("sglang.srt.entrypoints.openai.protocol")
view = protocol.ChatCompletionRequest(
model=request.model,
parallel_tool_calls=getattr(request, "parallel_tool_calls", None)
is not False,
messages=self._construct_input_messages(request, previous),
tools=self._response_tools_to_chat_tools(request) or None,
chat_template=getattr(request, "chat_template", None),
Expand Down
12 changes: 9 additions & 3 deletions src/art_inference/vllm.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from .append_only import (
aligned_values,
chat_prefix_scope,
chat_response_prefixes,
merge_chat_delta,
openai_tool_arguments,
Expand Down Expand Up @@ -291,9 +292,11 @@ async def create(self, request, raw_request=None):
getattr(request, "chat_template_kwargs", None),
headers.get("authorization", ""),
]
scope = hashlib.sha256(
json.dumps(material, sort_keys=True).encode()
).hexdigest()
scope = chat_prefix_scope(
hashlib.sha256(
json.dumps(material, sort_keys=True).encode()
).hexdigest()
)
turn = _Turn(
scope,
tokenizer,
Expand Down Expand Up @@ -353,6 +356,9 @@ async def observe_response(response):
)
view = protocol.ChatCompletionRequest(
model=request.model,
parallel_tool_calls=getattr(request, "parallel_tool_calls", None)
is not False,
tools=tools or None,
messages=[
openai_tool_arguments(message) for message in conversation
],
Expand Down
100 changes: 100 additions & 0 deletions tests/unit/test_append_only.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from art.token_prefix import TokenPrefixCache, apply_prefix_edits
from art.utils.append_only import chat_prefix_observations, chat_response_prefixes
from art_inference.append_only import (
chat_prefix_scope,
output_prefix_observations,
patch_deepseek_renderer,
)
Expand Down Expand Up @@ -57,6 +58,105 @@ def decode(self, tokens, *, skip_special_tokens=False):
return bytes(tokens).decode()


@pytest.mark.parametrize("parallel", [False, True, None])
@pytest.mark.parametrize("visible_calls", [0, 1, 2])
@pytest.mark.parametrize("reasoning", [False, True])
def test_serial_projection_cannot_certify_hidden_sampled_actions(
parallel, visible_calls, reasoning
):
class Request(BaseModel):
messages: list[dict]
parallel_tool_calls: bool | None = None
tools: list[dict] = [{"type": "function"}]

tokenizer = Tokenizer()
encode = tokenizer.encode
request = Request(messages=[{"role": "user"}], parallel_tool_calls=parallel)
calls = [
{"type": "function", "function": {"name": name, "arguments": "{}"}}
for name in ("first", "second")
]
message = {"role": "assistant", "tool_calls": calls[:visible_calls]}
if reasoning:
message["reasoning_content"] = "thought"

async def render(value):
if len(value.messages) == 1:
return encode("prompt:")
assistant = value.messages[-1]
text = "thought#" if assistant.get("reasoning_content") else ""
text += "".join(
call["function"]["name"] for call in assistant.get("tool_calls", [])
)
return encode("prompt:" + text + "END")

sampled = encode(("\nthought#" if reasoning else "") + "firstsecondEND")
choices = [(message, sampled, True), (message, sampled, False)]
before = deepcopy((request.model_dump(), choices))
entries = asyncio.run(
chat_response_prefixes(tokenizer, request, encode("prompt:"), choices, render)
)
full = [entry for entry in entries if entry[1] == encode("prompt:") + sampled]
assert len(full) == (0 if parallel is False else 1)
if parallel is False:
assert all(b"first" not in bytes(entry[1]) for entry in entries)
# A distinct, aligned reasoning boundary survives only if the parsed
# action makes it distinguishable from the reasoning-only rendering.
assert bool(entries) == bool(reasoning and visible_calls)
assert (request.model_dump(), choices) == before
for rendered, raw, edits in entries:
assert apply_prefix_edits(rendered, edits) == raw


def test_serial_non_tool_turn_retains_full_certificate():
class Request(BaseModel):
messages: list[dict] = []
parallel_tool_calls: bool = False

async def render(value):
return [1, 2] if value.messages else [1]

entries = asyncio.run(
chat_response_prefixes(
Tokenizer(),
Request(),
[1],
[
(
{
"role": "assistant",
"tool_calls": [
{"function": {"name": "first", "arguments": "{}"}}
],
},
[3, 2],
True,
),
({"role": "assistant"}, [2], True),
],
render,
)
)
assert len(entries) == 1 and entries[0][1] == [1, 2]


def test_policy_scope_keeps_old_certificates_in_a_separate_namespace():
from art_inference.token_prefix import TokenPrefixStore

cache = TokenPrefixStore()
base = "a" * 64
current = chat_prefix_scope(base)
assert current != base
assert len(current) == 64
assert current == chat_prefix_scope(base)
for canonical in ([1, 2], [1, 2, 3]):
cache.insert(base, canonical, [9], "lineage")
assert cache.lookup(current, [1, 2, 3, 4], "lineage") is None
cache.insert(current, [1, 2], [8], "lineage")
match = cache.lookup(current, [1, 2, 3, 4], "lineage")
assert match is not None and match.raw_prefix == (8,)


def test_many_protocol_markers_do_not_multiply_long_prompt_storage():
tokenizer = SimpleNamespace(
all_special_ids=[1, 2, 3],
Expand Down
Loading
Loading