Files
turnstone/tests/test_judge_parallel_live.py
2026-08-08 23:56:15 -07:00

383 lines
14 KiB
Python

"""Live request-count assertions for parallel intent-judge batches.
These tests run :class:`turnstone.core.judge.IntentJudge` through the real
OpenAI-compatible backend configured by ``TURNSTONE_TEST_BASE_URL`` (the same
contract as :mod:`tests.test_server_live`). A local threaded proxy counts the
HTTP fan-out and fully drains each genuine upstream response. It then returns
a deterministic, valid Chat Completions SSE verdict so the assertions measure
Turnstone's scheduler rather than a live model's JSON-formatting reliability.
Run explicitly with a live backend::
pytest tests/test_judge_parallel_live.py -m live -v
"""
from __future__ import annotations
import json
import threading
from collections import Counter
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import TYPE_CHECKING, Any
import httpx
import pytest
from openai import OpenAI
from tests.test_model_admission_live import (
_HOP_BY_HOP_HEADERS,
_LIVE_API_KEY,
_LIVE_BASE_URL,
_ConcurrencyCounter,
)
from turnstone.core.judge import IntentJudge, IntentVerdict, JudgeConfig
from turnstone.core.model_registry import ModelConfig, ModelRegistry
from turnstone.core.model_turn import resolve_model_binding
if TYPE_CHECKING:
from types import TracebackType
_VERDICT_CONTENT = json.dumps(
{
"intent_summary": "Live scheduler probe",
"risk_level": "low",
"confidence": 0.99,
"recommendation": "approve",
"reasoning": "The deterministic proxy verdict confirms one completed judge dispatch.",
"evidence": ["The genuine upstream response was drained before this verdict."],
},
separators=(",", ":"),
)
def _deterministic_sse() -> bytes:
chunks = (
{
"id": "turnstone-live-judge",
"object": "chat.completion.chunk",
"created": 0,
"model": "turnstone-live-judge",
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": _VERDICT_CONTENT},
"finish_reason": None,
}
],
},
{
"id": "turnstone-live-judge",
"object": "chat.completion.chunk",
"created": 0,
"model": "turnstone-live-judge",
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop",
}
],
},
)
framed = "".join(f"data: {json.dumps(chunk, separators=(',', ':'))}\n\n" for chunk in chunks)
return (framed + "data: [DONE]\n\n").encode()
class _JudgeCountingProxyServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, counter: _ConcurrencyCounter) -> None:
super().__init__(("127.0.0.1", 0), _JudgeCountingProxyHandler)
self.counter = counter
self.upstream_base_url = _LIVE_BASE_URL.rstrip("/")
self.upstream_authorization = f"Bearer {_LIVE_API_KEY}"
self.alias_by_authorization: dict[str, str] = {}
class _JudgeCountingProxyHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
@property
def _proxy(self) -> _JudgeCountingProxyServer:
if not isinstance(self.server, _JudgeCountingProxyServer):
raise TypeError("judge counting handler requires _JudgeCountingProxyServer")
return self.server
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
alias = self._proxy.alias_by_authorization.get(self.headers.get("Authorization", ""))
if alias is None:
self._write_json_error(401, "unknown live-test alias credential")
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self._write_json_error(400, "invalid content length")
return
body = self.rfile.read(content_length)
# The test measures dispatch, admission, and full upstream drain. Keep
# the real probe inexpensive even if the configured model tends to
# deliberate: its content is replaced only after the response completes.
try:
upstream_body = json.loads(body)
except (json.JSONDecodeError, UnicodeDecodeError):
upstream_body = None
if isinstance(upstream_body, dict):
if "max_tokens" in upstream_body:
upstream_body["max_tokens"] = min(int(upstream_body["max_tokens"]), 16)
if "max_completion_tokens" in upstream_body:
upstream_body["max_completion_tokens"] = min(
int(upstream_body["max_completion_tokens"]), 16
)
body = json.dumps(upstream_body, separators=(",", ":")).encode()
counter = self._proxy.counter
counter.enter(alias)
completed = False
try:
headers = {
key: value
for key, value in self.headers.items()
if key.lower() not in _HOP_BY_HOP_HEADERS
and key.lower() not in {"authorization", "host"}
}
headers["Authorization"] = self._proxy.upstream_authorization
headers["Accept-Encoding"] = "identity"
headers["Content-Length"] = str(len(body))
counter.begin_forward(alias)
try:
upstream = httpx.post(
f"{self._proxy.upstream_base_url}{self.path}",
content=body,
headers=headers,
timeout=httpx.Timeout(60.0, connect=5.0),
)
finally:
counter.end_forward(alias)
# ``httpx.post`` has consumed the complete streaming body here.
# A genuine backend rejection must fail the probe; only a successful
# response is replaced with deterministic judge output.
upstream.raise_for_status()
payload = _deterministic_sse()
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(payload)
self.wfile.flush()
completed = True
except Exception:
self._write_json_error(502, "live backend judge proxy failure")
finally:
counter.leave(alias, completed=completed)
def log_message(self, _format: str, *args: Any) -> None:
"""Keep expected local proxy traffic out of pytest output."""
def _write_json_error(self, status: int, detail: str) -> None:
payload = json.dumps({"error": {"message": detail}}).encode()
try:
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(payload)
self.wfile.flush()
except (BrokenPipeError, ConnectionError):
pass
class _JudgeCountingProxy:
def __init__(self, counter: _ConcurrencyCounter) -> None:
self._server = _JudgeCountingProxyServer(counter)
self._thread = threading.Thread(
target=self._server.serve_forever,
name="judge-parallel-live-proxy",
daemon=True,
)
@property
def base_url(self) -> str:
host, port = self._server.server_address[:2]
if not isinstance(host, str) or not isinstance(port, int):
raise TypeError("judge counting proxy did not bind an IPv4 TCP address")
return f"http://{host}:{port}"
def credential_for(self, alias: str) -> str:
credential = f"turnstone-live-judge-{alias}"
self._server.alias_by_authorization[f"Bearer {credential}"] = alias
return credential
def __enter__(self) -> _JudgeCountingProxy:
self._thread.start()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
del exc_type, exc_value, traceback
self._server.shutdown()
self._server.server_close()
self._thread.join(timeout=5.0)
if self._thread.is_alive():
raise AssertionError("live judge counting proxy did not stop")
@pytest.fixture(scope="module")
def live_model_id() -> str:
"""Auto-detect the model using the established live-backend contract."""
client = OpenAI(base_url=_LIVE_BASE_URL, api_key=_LIVE_API_KEY)
try:
models = client.models.list()
finally:
client.close()
ids = [model.id for model in models.data]
assert ids, "No models found on the live backend"
return ids[0]
@dataclass(frozen=True)
class _JudgeRun:
heuristics: list[IntentVerdict]
finals: list[IntentVerdict]
done_calls: int
def _run_live_judge_batch(
proxy: _JudgeCountingProxy,
model: str,
*,
alias_limit: int,
parallel_evaluations: int,
batch_size: int,
) -> _JudgeRun:
alias = "live-judge"
config = ModelConfig(
alias=alias,
base_url=proxy.base_url,
api_key=proxy.credential_for(alias),
model=model,
provider="openai-compatible",
max_concurrency=alias_limit,
)
registry = ModelRegistry({alias: config}, default=alias)
done = threading.Event()
finals: list[IntentVerdict] = []
done_calls = 0
def _done() -> None:
nonlocal done_calls
done_calls += 1
done.set()
try:
binding = resolve_model_binding(registry, alias)
judge = IntentJudge(
JudgeConfig(
enabled=True,
read_only_tools=False,
timeout=60.0,
parallel_evaluations=parallel_evaluations,
),
binding,
)
items = [
{
"func_name": "bash",
"func_args": {"command": f"echo live-{index}"},
"approval_label": "bash",
"call_id": f"live-call-{index}",
}
for index in range(batch_size)
]
heuristics = judge.evaluate(
items,
[{"role": "user", "content": "Run the independent live scheduler probes."}],
finals.append,
done_callback=_done,
)
assert done.wait(90.0), "live intent-judge batch did not finish"
return _JudgeRun(heuristics=heuristics, finals=finals, done_calls=done_calls)
finally:
registry.shutdown()
def _assert_exact_batch(run: _JudgeRun, *, batch_size: int) -> None:
expected_ids = {f"live-call-{index}" for index in range(batch_size)}
assert len(run.heuristics) == batch_size
assert len(run.finals) == batch_size
assert Counter(verdict.call_id for verdict in run.finals) == Counter(
{call_id: 1 for call_id in expected_ids}
)
assert all(verdict.tier == "llm" for verdict in run.finals)
assert all(verdict.intent_summary == "Live scheduler probe" for verdict in run.finals)
assert run.done_calls == 1
@pytest.mark.live
class TestLiveParallelJudgeCounts:
"""Count genuine upstream calls made by the intent-judge scheduler."""
def test_parallel_width_sets_exact_batch_peak(self, live_model_id: str) -> None:
counter = _ConcurrencyCounter(rendezvous_size=3)
with _JudgeCountingProxy(counter) as proxy:
run = _run_live_judge_batch(
proxy,
live_model_id,
alias_limit=0,
parallel_evaluations=3,
batch_size=5,
)
_assert_exact_batch(run, batch_size=5)
counts = counter.snapshot()
assert counts.requests == 5
assert counts.forwarded == 5
assert counts.completed == 5
assert counts.failed == 0
assert counts.active == 0
assert counts.peak == 3
assert counts.forward_active == 0
assert counts.forward_peak == 3
assert counts.requests_by_alias == {"live-judge": 5}
assert counts.forwarded_by_alias == {"live-judge": 5}
assert counts.completed_by_alias == {"live-judge": 5}
assert counts.peak_by_alias == {"live-judge": 3}
assert counts.forward_peak_by_alias == {"live-judge": 3}
assert counts.rendezvous_timed_out is False
def test_alias_cap_reduces_parallel_judge_peak(self, live_model_id: str) -> None:
counter = _ConcurrencyCounter(rendezvous_size=2)
with _JudgeCountingProxy(counter) as proxy:
run = _run_live_judge_batch(
proxy,
live_model_id,
alias_limit=2,
parallel_evaluations=4,
batch_size=4,
)
_assert_exact_batch(run, batch_size=4)
counts = counter.snapshot()
assert counts.requests == 4
assert counts.forwarded == 4
assert counts.completed == 4
assert counts.failed == 0
assert counts.active == 0
assert counts.peak == 2
assert counts.forward_active == 0
assert counts.forward_peak == 2
assert counts.requests_by_alias == {"live-judge": 4}
assert counts.forwarded_by_alias == {"live-judge": 4}
assert counts.completed_by_alias == {"live-judge": 4}
assert counts.peak_by_alias == {"live-judge": 2}
assert counts.forward_peak_by_alias == {"live-judge": 2}
assert counts.rendezvous_timed_out is False