From bc52147c85c8ff4fb390e3b76f4ac63b44d020f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 14:06:08 +0000 Subject: [PATCH 1/3] PR-B3 (ADR 0008 Phase B): Generate server-streaming RPC + greedy session-aware decoding First Phase-B PR with mandatory Mac M4 integration test report under \u00a79 (Linux CI is necessary but not sufficient). Design choice: PR-B3 ships **greedy decoding only**. Speculative- decoding integration (DLM proposer + AR verifier rejection sampling) is reserved for a later PR. The wire contract is algorithm-agnostic (GenerateResponse oneof on token_id / done / truncated), so the upgrade path lands without breaking clients. Three deliverables: 1. inference_engine/session/generator.py (new, ~240 lines) - VerifierProtocol-driven GenerationCoordinator. - Yields TokenEvent / HistoryTruncatedEvent / DoneEvent. - Strict v0.3 validation: temperature/top_p/top_k must be in greedy no-op defaults; max_tokens must be >= 1; session must have prior AppendTokens. Per \u00a72.10 'no graceful degradation', any deviation raises ValueError -> INVALID_ARGUMENT. - HistoryTruncated emitted at most once per call, BEFORE any TokenEvent, when the cache is in sink+window-truncated state (matches the runtime.proto contract exactly). - INV-1 / INV-2 enforced via SessionStore at every step. - INV-3 byte-exact under greedy: same (session_id, history) -> bit-identical token stream. Tested with FakeVerifier on Linux and asserted in TestDeterminism. 2. inference_engine/server/grpc_app.py (modified, +119 lines) - RuntimeServiceServicer.__init__ takes optional generation_coordinator: GenerationCoordinator. None = PR-B2 UNIMPLEMENTED default preserved (regression-tested). - Generate RPC implements server-streaming with the four typed error mappings: SessionNotFoundError -> NOT_FOUND ValueError -> INVALID_ARGUMENT InvariantViolation -> FAILED_PRECONDITION Plus the success path streams TokenEvent -> token_id, HistoryTruncatedEvent -> truncated, DoneEvent -> done. - Cancellation: polls context.cancelled() between events; on True, emits a final GenerateDone(STOP_REASON_CANCELLED) and returns. Cancellation latency is bounded by one generation step (the in-flight forward pass finishes before the next poll). - create_grpc_server factory plumbed with the new keyword. 3. tests/inference_engine/session/test_generator.py (new, 31 tests) - TestGreedyHappyPath: 4 tests on token-then-done emission, max_tokens cap, single Done event, total_seconds reporting. - TestGreedyAdvancesVerifier: 4 tests confirming verifier state mirrored onto session after every step. - TestEos: 3 tests on EOS detection + STOP_REASON_EOS. - TestHistoryTruncated: 3 tests on at-start emission + at-most- once contract. - TestValidation: 9 tests on max_tokens, sampling params, and no-AppendTokens-prior rejection. - TestInvariants: 2 tests on INV-1 / INV-2 propagation. - TestDeterminism: 1 test on byte-exact greedy output across parallel sessions. - TestConstructorAndEventDataclasses: 4 tests on frozen dataclass contract and constructor. + tests/inference_engine/server/test_grpc_app.py (extended, +11 tests) - Generate streams tokens then done. - EOS triggers STOP_REASON_EOS. - HistoryTruncated frame emitted before tokens. - Unknown session -> NOT_FOUND. - No AppendTokens prior -> INVALID_ARGUMENT. - max_tokens=0 -> INVALID_ARGUMENT. - temperature=0.7 -> INVALID_ARGUMENT. - seed accepted on the wire. - InvariantViolation during generation -> FAILED_PRECONDITION. - Cancellation -> STOP_REASON_CANCELLED Done frame (direct Servicer invocation with FakeContext; cancellation latency bounded as documented). - Factory accepts generation_coordinator keyword. Mac M4 reviewer aids: scripts/smoke_grpc_generator.py 10-scenario smoke walking Generate scenarios with Append + Generate coordinators wired against FakeVerifier. scripts/review_pr_b3_on_mac.sh One-shot Mac M4 reviewer producing 5 JSON artifacts: pr-b3-mac-generator-tests-.json (31 tests, 100% on generator.py) pr-b3-mac-grpc-tests-.json (39 tests after PR-B3 additions, 100% on grpc_app.py) pr-b3-mac-grpc-runtime-smoke-.json (PR-B1 regression) pr-b3-mac-grpc-appender-smoke-.json (PR-B2 regression) pr-b3-mac-grpc-generator-smoke-.json (PR-B3 new) Local verification (Linux VM, py3.12): Linux CI gate: 629 passed (was 587 + 31 generator + 11 grpc generate). Coverage 100.00% on 1521 stmts (was 1428 + 63 generator + 30 grpc_app additions - 0 deletions). Per ADR 0008 \u00a79: this PR introduces a NEW MLX-reachable runtime path (verifier.forward_block + commit_or_truncate driven by the coordinator's per-token loop). Even though FakeVerifier covers the Linux-side dispatch logic exhaustively, the v0.3 GA contract requires a Mac M4 integration test report on the PR branch before merge. The carve-out paragraph does NOT apply here. PR is opened as Draft pending the report. Mac M4 verification command for the PR branch: bash scripts/review_pr_b3_on_mac.sh The script writes 5 JSON artifacts under results/platform-tests/; the user commits them back to this branch and the PR description is updated to quote the smoke summary line. Co-authored-by: FluffyAIcode --- inference_engine/server/grpc_app.py | 152 +++++- inference_engine/session/__init__.py | 20 + inference_engine/session/generator.py | 242 ++++++++++ scripts/review_pr_b3_on_mac.sh | 183 ++++++++ scripts/smoke_grpc_generator.py | 363 +++++++++++++++ .../inference_engine/server/test_grpc_app.py | 356 +++++++++++++- .../session/test_generator.py | 433 ++++++++++++++++++ 7 files changed, 1739 insertions(+), 10 deletions(-) create mode 100644 inference_engine/session/generator.py create mode 100755 scripts/review_pr_b3_on_mac.sh create mode 100755 scripts/smoke_grpc_generator.py create mode 100644 tests/inference_engine/session/test_generator.py diff --git a/inference_engine/server/grpc_app.py b/inference_engine/server/grpc_app.py index 7446c0ed..c9aaf5b0 100644 --- a/inference_engine/server/grpc_app.py +++ b/inference_engine/server/grpc_app.py @@ -42,11 +42,34 @@ ) from inference_engine.session import ( AppendTokensCoordinator, + DoneEvent, + GenerationCoordinator, + HistoryTruncatedEvent, InvariantViolation, SessionNotFoundError, SessionStore, + STOP_REASON_CANCELLED, + STOP_REASON_EOS, + STOP_REASON_MAX_TOKENS, + STOP_REASON_TRUNCATED, + TokenEvent, ) + +# Mapping from GenerationCoordinator's string stop reasons to the +# protobuf enum. Defined at module level so reviewers can audit the +# 1:1 correspondence at a glance. +_STOP_REASON_TO_PROTO = { + STOP_REASON_MAX_TOKENS: + runtime_pb2.GenerateDone.STOP_REASON_MAX_TOKENS, + STOP_REASON_EOS: + runtime_pb2.GenerateDone.STOP_REASON_EOS, + STOP_REASON_CANCELLED: + runtime_pb2.GenerateDone.STOP_REASON_CANCELLED, + STOP_REASON_TRUNCATED: + runtime_pb2.GenerateDone.STOP_REASON_TRUNCATED, +} + _logger = logging.getLogger(__name__) DEFAULT_BIND_ADDRESS = "127.0.0.1:50051" @@ -113,18 +136,25 @@ def __init__( session_store: SessionStore, *, append_coordinator: Optional[AppendTokensCoordinator] = None, + generation_coordinator: Optional[GenerationCoordinator] = None, ) -> None: """Construct a Servicer. - ``append_coordinator`` is the wiring point added in PR-B2. When - ``None`` (the PR-B1 mode, preserved for tests that don't need a - verifier), ``AppendTokens`` returns ``UNIMPLEMENTED`` — the same - framework default used in PR-B1. When non-None, ``AppendTokens`` - runs the §2.3 byte-exact prefill-incremental contract through - the coordinator and surfaces the typed error mapping above. + ``append_coordinator`` is the PR-B2 wiring point: when None + (PR-B1 mode, preserved for tests that don't need a verifier), + ``AppendTokens`` returns ``UNIMPLEMENTED``; when non-None, + ``AppendTokens`` runs the §2.3 byte-exact prefill-incremental + contract. + + ``generation_coordinator`` is the PR-B3 wiring point: same + optional-default contract for ``Generate``. When None, the + Generate stream returns ``UNIMPLEMENTED``; when non-None, + Generate streams TokenEvents / HistoryTruncatedEvents / + DoneEvent through the gRPC server-streaming response. """ self._store = session_store self._append = append_coordinator + self._generate = generation_coordinator async def CreateSession( # noqa: N802 — gRPC-generated method casing self, @@ -184,6 +214,111 @@ async def AppendTokens( # noqa: N802 — gRPC-generated method casing history_length=new_history_length, ) + async def Generate( # noqa: N802 — gRPC-generated method casing + self, + request: runtime_pb2.GenerateRequest, + context: grpc.aio.ServicerContext, + ): + """Stream tokens generated against ``request.session_id``. + + Yields ``runtime_pb2.GenerateResponse`` frames carrying one of: + + * ``token_id``: a committed token, in generation order. + * ``truncated``: ``HistoryTruncated`` event, emitted at most + once per call before the first ``token_id`` (per the proto + contract). + * ``done``: ``GenerateDone`` terminal frame. + + When this Servicer was constructed without a + ``generation_coordinator``, returns ``UNIMPLEMENTED`` (PR-B2 + regression contract preserved). + + Cancellation: the loop polls ``context.cancelled()`` after + every event the coordinator yields. On cancellation we emit + a ``GenerateDone(STOP_REASON_CANCELLED)`` frame and return. + Cancellation latency is bounded by one generation step on + the worst case (the in-flight forward pass finishes before + the next poll). + """ + if self._generate is None: + await context.abort( + grpc.StatusCode.UNIMPLEMENTED, + "Generate not configured on this Servicer " + "(coordinator not provided)", + ) + + seed = request.seed if request.HasField("seed") else None + temperature = ( + request.temperature + if request.HasField("temperature") else None + ) + top_p = request.top_p if request.HasField("top_p") else None + top_k = request.top_k if request.HasField("top_k") else None + + # GenerationCoordinator.generate is a generator function; the + # call itself returns a generator object without executing + # any of the body, so no exception is raised here. All typed + # errors (SessionNotFoundError, ValueError, InvariantViolation) + # propagate from the inner `for event in event_stream:` loop + # below and are caught there. + event_stream = self._generate.generate( + session_id=request.session_id, + max_tokens=request.max_tokens, + seed=seed, + temperature=temperature, + top_p=top_p, + top_k=top_k, + ) + + token_count_so_far = 0 + + try: + for event in event_stream: + if context.cancelled(): + yield runtime_pb2.GenerateResponse( + done=runtime_pb2.GenerateDone( + stop_reason=_STOP_REASON_TO_PROTO[ + STOP_REASON_CANCELLED + ], + generated_token_count=token_count_so_far, + prefill_duration_seconds=0.0, + total_duration_seconds=0.0, + ), + ) + return + + if isinstance(event, TokenEvent): + token_count_so_far += 1 + yield runtime_pb2.GenerateResponse( + token_id=event.token_id, + ) + elif isinstance(event, HistoryTruncatedEvent): + yield runtime_pb2.GenerateResponse( + truncated=runtime_pb2.HistoryTruncated( + dropped_token_count=event.dropped_token_count, + ), + ) + else: + # DoneEvent — the only remaining event type per + # the GenerateEvent union. + assert isinstance(event, DoneEvent) + yield runtime_pb2.GenerateResponse( + done=runtime_pb2.GenerateDone( + stop_reason=_STOP_REASON_TO_PROTO[ + event.stop_reason + ], + generated_token_count=event.generated_token_count, + prefill_duration_seconds=event.prefill_seconds, + total_duration_seconds=event.total_seconds, + ), + ) + except SessionNotFoundError as exc: + await context.abort(grpc.StatusCode.NOT_FOUND, str(exc)) + except ValueError as exc: + await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(exc)) + except InvariantViolation as exc: + await context.abort(grpc.StatusCode.FAILED_PRECONDITION, str(exc)) + async def CloseSession( # noqa: N802 self, request: runtime_pb2.CloseSessionRequest, @@ -233,6 +368,7 @@ def create_grpc_server( *, session_store: SessionStore, append_coordinator: Optional[AppendTokensCoordinator] = None, + generation_coordinator: Optional[GenerationCoordinator] = None, config: Optional[GrpcServerConfig] = None, ) -> grpc.aio.Server: """Build, but do not start, a configured gRPC asyncio server. @@ -264,7 +400,9 @@ def create_grpc_server( ) runtime_pb2_grpc.add_RuntimeServiceServicer_to_server( RuntimeServiceServicer( - session_store, append_coordinator=append_coordinator, + session_store, + append_coordinator=append_coordinator, + generation_coordinator=generation_coordinator, ), server, ) diff --git a/inference_engine/session/__init__.py b/inference_engine/session/__init__.py index 5cae8c9f..2e5ebe3e 100644 --- a/inference_engine/session/__init__.py +++ b/inference_engine/session/__init__.py @@ -20,6 +20,17 @@ AppendTokensCoordinator, VerifierProtocol, ) +from inference_engine.session.generator import ( + DoneEvent, + GenerateEvent, + GenerationCoordinator, + HistoryTruncatedEvent, + STOP_REASON_CANCELLED, + STOP_REASON_EOS, + STOP_REASON_MAX_TOKENS, + STOP_REASON_TRUNCATED, + TokenEvent, +) from inference_engine.session.store import ( CacheInspector, InvariantViolation, @@ -32,10 +43,19 @@ __all__ = [ "AppendTokensCoordinator", "CacheInspector", + "DoneEvent", + "GenerateEvent", + "GenerationCoordinator", + "HistoryTruncatedEvent", "InvariantViolation", + "STOP_REASON_CANCELLED", + "STOP_REASON_EOS", + "STOP_REASON_MAX_TOKENS", + "STOP_REASON_TRUNCATED", "Session", "SessionNotFoundError", "SessionStore", "SessionStoreError", + "TokenEvent", "VerifierProtocol", ] diff --git a/inference_engine/session/generator.py b/inference_engine/session/generator.py new file mode 100644 index 00000000..a34efd26 --- /dev/null +++ b/inference_engine/session/generator.py @@ -0,0 +1,242 @@ +"""GenerationCoordinator — ADR 0008 PR-B3 (Phase B). + +Session-aware token generation against a verifier. v0.3 ships +**greedy decoding only**; speculative-decoding integration (the +DLM proposer + AR verifier rejection sampling that is Kakeya's +distinguishing feature) is reserved for a later PR. The wire +contract — :class:`runtime_pb2.GenerateResponse` with its +``token_id`` / ``done`` / ``truncated`` ``oneof`` payload — is +algorithm-agnostic, so the upgrade path lands without breaking +clients. + +The coordinator yields a stream of typed events: + + * :class:`TokenEvent` — one per committed token, in order + * :class:`HistoryTruncatedEvent` — emitted at most once at the + start of a Generate call when the session is already operating + in sink+window-truncated mode (per `runtime.proto` contract: + "Emitted at most once per Generate call, before any token_id + event in that call.") + * :class:`DoneEvent` — terminal; emitted exactly once at the end + +Layering note: this coordinator depends on the same +:class:`VerifierProtocol` PR-B2 introduced. It does not call +``verifier.prefill`` — that is the AppendTokens path's +responsibility (PR-B2). Generate operates on whatever cache state +:meth:`AppendTokensCoordinator.append_tokens` left behind, which +is precisely the byte-exact prefill-incremental contract from +ADR 0008 §2.3 in action. + +Anomaly invariants: + + * INV-1 (parallel-sequence consistency): enforced after every + generated token via :meth:`SessionStore.append_tokens`'s + INV-1 check (the same check PR-B2's coordinator triggers on + user-submitted tokens). + * INV-2 (position monotonicity): enforced after every token + via :meth:`SessionStore.record_position_advance`. + * INV-3 (continuation-path determinism): for the same + ``(session_id, history_token_ids)`` pair under greedy + decoding, repeated Generate calls produce bit-identical token + sequences. Tested with a deterministic ``FakeVerifier`` in + the unit suite and against the real Qwen3 verifier under + ``tests/core/`` on Mac M4. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Iterator, Optional, Union + +import torch + +from inference_engine.session.coordinator import VerifierProtocol +from inference_engine.session.store import SessionStore + + +# Stop-reason string constants. Mirror the protobuf enum but defined +# here so this module has no protobuf dependency (the gRPC servicer +# does the string -> enum translation). +STOP_REASON_MAX_TOKENS = "max_tokens" +STOP_REASON_EOS = "eos" +STOP_REASON_CANCELLED = "cancelled" +STOP_REASON_TRUNCATED = "truncated" + + +@dataclass(frozen=True) +class TokenEvent: + """One committed token, yielded in generation order.""" + + token_id: int + + +@dataclass(frozen=True) +class HistoryTruncatedEvent: + """Cache no longer holds the full session history. + + ``dropped_token_count`` is the difference between the session's + full history length and what the verifier's sink+window cache + currently holds. Per the runtime contract this event is + emitted at most once per Generate call, before any TokenEvent. + """ + + dropped_token_count: int + + +@dataclass(frozen=True) +class DoneEvent: + """Terminal event for a Generate call. + + ``prefill_seconds`` is 0.0 in PR-B3 because Generate has no + separate prefill phase — the prefill ran inside the preceding + AppendTokens call. The field is preserved on the wire for + forward-compatibility with future PRs that re-introduce a + prefill step (e.g., for speculative-decoding warmup). + """ + + stop_reason: str + generated_token_count: int + prefill_seconds: float + total_seconds: float + + +GenerateEvent = Union[TokenEvent, HistoryTruncatedEvent, DoneEvent] + + +class GenerationCoordinator: + """Greedy session-aware token generation against a verifier.""" + + def __init__( + self, + store: SessionStore, + verifier: VerifierProtocol, + ) -> None: + self._store = store + self._verifier = verifier + + def generate( + self, + session_id: str, + *, + max_tokens: int, + seed: Optional[int] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + top_k: Optional[int] = None, + ) -> Iterator[GenerateEvent]: + """Yield a stream of GenerateEvents for ``session_id``. + + Raises: + * :class:`SessionNotFoundError` — unknown / closed / evicted + session id. + * :class:`ValueError` — invalid argument (e.g., + ``max_tokens < 1``, sampling param set in v0.3 greedy + mode, no AppendTokens preceded this call). + * :class:`InvariantViolation` — INV-1 / INV-2 violation + during a generation step. + + v0.3 greedy contract: + * ``temperature`` / ``top_p`` / ``top_k`` MUST be unset + (or in their no-op default of 0 / unset / 1 + respectively). Setting any of them raises ValueError — + the runtime refuses to silently downgrade a non-greedy + request to greedy (per ADR 0008 §2.10 "no graceful + degradation"). + * ``seed`` is accepted (per OQ-4 default) but ignored: + greedy decoding has no RNG to seed, and the + byte-exact contract over a fixed seed reduces to the + byte-exact contract under any seed because there is no + seed-dependent randomness. + """ + if max_tokens < 1: + raise ValueError( + f"max_tokens must be >= 1, got {max_tokens}" + ) + if temperature is not None and float(temperature) != 0.0: + raise ValueError( + f"v0.3 supports only greedy decoding; temperature " + f"must be 0 or unset, got {temperature}" + ) + if top_p is not None: + raise ValueError( + "v0.3 supports only greedy decoding; top_p must be " + "unset (greedy ignores it)" + ) + if top_k is not None and int(top_k) != 1: + raise ValueError( + f"v0.3 supports only greedy decoding; top_k must be " + f"1 or unset, got {top_k}" + ) + # seed is accepted but not used in greedy; explicitly ignore. + del seed + + session = self._store.get_session(session_id) + if session.next_global_position == 0: + raise ValueError( + "session has no history yet; AppendTokens must " + "precede Generate (the first token's logits are " + "the prefill's last position)" + ) + + # Emit HistoryTruncated at start if the cache is already in + # truncated mode. Per the proto contract, this event is + # emitted at most once per Generate call and BEFORE any + # token_id event — we honor both by checking once at the + # start and never emitting again during this call. + history_len = len(session.history_token_ids) + cached_len = len(session.cached_token_sequence) + if history_len > cached_len: + yield HistoryTruncatedEvent( + dropped_token_count=history_len - cached_len, + ) + + eos_set = set(session.eos_token_ids) + t0 = time.perf_counter() + # Generate has no separate prefill phase in PR-B3; report 0. + prefill_seconds = 0.0 + generated_count = 0 + + for _step in range(max_tokens): + # Greedy: argmax of the verifier's last next_token_logits. + next_token = int( + torch.argmax(self._verifier.next_token_logits).item() + ) + + # Forward + commit (forwarded == accepted for prompt-mode + # appends; same contract used by AppendTokens, just one + # token at a time). + block_logits = self._verifier.forward_block([next_token]) + self._verifier.commit_or_truncate(forwarded=1, accepted=1) + self._verifier.next_token_logits = block_logits[-1].clone() + + # Mirror state from verifier onto session BEFORE the + # store's INV-1 check runs (it compares + # session.cached_token_sequence length against + # verifier.k_seq_length). + session.cached_token_sequence = list( + self._verifier.cached_token_sequence, + ) + self._store.append_tokens(session_id, [next_token]) + self._store.record_position_advance( + session_id, self._verifier.next_global_position, + ) + generated_count += 1 + + yield TokenEvent(token_id=next_token) + + if next_token in eos_set: + yield DoneEvent( + stop_reason=STOP_REASON_EOS, + generated_token_count=generated_count, + prefill_seconds=prefill_seconds, + total_seconds=time.perf_counter() - t0, + ) + return + + yield DoneEvent( + stop_reason=STOP_REASON_MAX_TOKENS, + generated_token_count=generated_count, + prefill_seconds=prefill_seconds, + total_seconds=time.perf_counter() - t0, + ) diff --git a/scripts/review_pr_b3_on_mac.sh b/scripts/review_pr_b3_on_mac.sh new file mode 100755 index 00000000..7d9e4d2c --- /dev/null +++ b/scripts/review_pr_b3_on_mac.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Mac M4 review aid for PR-B3 (ADR 0008 Phase B, Generate RPC). +# +# Per ADR 0008 §9, PR-B3 is the FIRST Phase-B PR with a mandatory +# Mac M4 integration test report — Linux CI is necessary but not +# sufficient because Generate exercises the verifier-sampler path +# that has MLX-specific behavior (argmax on bf16 tensors, post-trim +# K/V tensor shapes, etc.). This script produces 5 artifacts under +# results/platform-tests/: +# +# 1. pr-b3-mac-generator-tests-.json +# pytest tests/inference_engine/session/test_generator.py +# (31 tests; 100% line coverage on +# inference_engine/session/generator.py). +# +# 2. pr-b3-mac-grpc-tests-.json +# pytest tests/inference_engine/server/test_grpc_app.py +# (39 tests after PR-B3 additions; 100% line coverage on +# inference_engine/server/grpc_app.py). +# +# 3. pr-b3-mac-grpc-runtime-smoke-.json +# Regression smoke: smoke_grpc_runtime.py (PR-B1 contract, +# AppendTokens + Generate stay UNIMPLEMENTED with a bare +# Servicer). +# +# 4. pr-b3-mac-grpc-appender-smoke-.json +# Regression smoke: smoke_grpc_appender.py (PR-B2 contract, +# AppendTokens reachable + Generate still UNIMPLEMENTED). +# +# 5. pr-b3-mac-grpc-generator-smoke-.json +# New PR-B3 smoke: smoke_grpc_generator.py (10 RPC scenarios +# with Generate fully wired, including HistoryTruncated and +# STOP_REASON_EOS frames). +# +# Usage (from repo root, on Mac M4 / arm64): +# +# bash scripts/review_pr_b3_on_mac.sh +# +# Then commit the artifacts: +# +# git add results/platform-tests/pr-b3-mac-* +# git commit -m "Mac M4 review evidence for PR-B3" +# git push +# +# Same `coverage run -m pytest` + `--include` filter pattern as +# review_pr_b2_on_mac.sh — sidesteps the Python 3.13 / coverage / +# torch race documented in commit 9cb1c56 + 9d1a250 on PR #45. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +stamp="$(date +%s)" +out_dir="results/platform-tests" +mkdir -p "$out_dir" + +gen_junit="$out_dir/pr-b3-mac-generator-tests-${stamp}.junit.xml" +gen_cov="$out_dir/pr-b3-mac-generator-tests-${stamp}.coverage.xml" +gen_report="$out_dir/pr-b3-mac-generator-tests-${stamp}.json" + +grpc_junit="$out_dir/pr-b3-mac-grpc-tests-${stamp}.junit.xml" +grpc_cov="$out_dir/pr-b3-mac-grpc-tests-${stamp}.coverage.xml" +grpc_report="$out_dir/pr-b3-mac-grpc-tests-${stamp}.json" + +runtime_smoke="$out_dir/pr-b3-mac-grpc-runtime-smoke-${stamp}.json" +appender_smoke="$out_dir/pr-b3-mac-grpc-appender-smoke-${stamp}.json" +generator_smoke="$out_dir/pr-b3-mac-grpc-generator-smoke-${stamp}.json" + + +_summarize_pytest() { + # $1 junit $2 coverage $3 out $4 kind label + PYTHONPATH=. python3 - "$1" "$2" "$3" "$4" <<'PY' +import json, platform, sys, xml.etree.ElementTree as ET +junit_path, cov_path, out_path, kind = sys.argv[1:5] +jr = ET.parse(junit_path).getroot() + +# Aggregate counts from elements (pytest's --junitxml +# emits the counts on the inner , not on the +# wrapper). Same fix as commit 9d1a250. +testsuites = list(jr.iter("testsuite")) +total_tests = sum(int(ts.get("tests", "0")) for ts in testsuites) +total_failures = sum(int(ts.get("failures", "0")) for ts in testsuites) +total_errors = sum(int(ts.get("errors", "0")) for ts in testsuites) +total_skipped = sum(int(ts.get("skipped", "0")) for ts in testsuites) + +cases = [] +for tc in jr.iter("testcase"): + cases.append({ + "classname": tc.get("classname"), + "name": tc.get("name"), + "time": float(tc.get("time", 0.0)), + "outcome": ( + "failed" if tc.find("failure") is not None + else "errored" if tc.find("error") is not None + else "skipped" if tc.find("skipped") is not None + else "passed" + ), + }) + +cov_root = ET.parse(cov_path).getroot() +report = { + "schema_version": 1, + "kind": kind, + "host": { + "platform": platform.platform(), + "machine": platform.machine(), + "python": platform.python_version(), + }, + "junit": { + "tests": total_tests, + "failures": total_failures, + "errors": total_errors, + "skipped": total_skipped, + "cases": cases, + }, + "coverage": { + "line_rate": float(cov_root.get("line-rate", "0.0")), + "branch_rate": float(cov_root.get("branch-rate", "0.0")), + "lines_covered": int(cov_root.get("lines-covered", "0")), + "lines_valid": int(cov_root.get("lines-valid", "0")), + }, +} +with open(out_path, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2) +print(f" -> {out_path}") +PY +} + + +echo "==> [1/5] generator unit tests" +PYTHONPATH=. python3 -m coverage erase +PYTHONPATH=. python3 -m coverage run \ + -m pytest tests/inference_engine/session/test_generator.py \ + --junitxml="$gen_junit" -v +python3 -m coverage report \ + --include='inference_engine/session/generator.py' \ + --fail-under=100 -m +python3 -m coverage xml \ + --include='inference_engine/session/generator.py' \ + -o "$gen_cov" +_summarize_pytest "$gen_junit" "$gen_cov" "$gen_report" \ + "pr_b3_mac_generator_tests" + +echo +echo "==> [2/5] gRPC tests (PR-B1 + PR-B2 + PR-B3 surface)" +PYTHONPATH=. python3 -m coverage erase +PYTHONPATH=. python3 -m coverage run \ + -m pytest tests/inference_engine/server/test_grpc_app.py \ + --junitxml="$grpc_junit" -v +python3 -m coverage report \ + --include='inference_engine/server/grpc_app.py' \ + --fail-under=100 -m +python3 -m coverage xml \ + --include='inference_engine/server/grpc_app.py' \ + -o "$grpc_cov" +_summarize_pytest "$grpc_junit" "$grpc_cov" "$grpc_report" \ + "pr_b3_mac_grpc_tests" + +echo +echo "==> [3/5] runtime smoke (PR-B1 contract regression)" +PYTHONPATH=. python3 scripts/smoke_grpc_runtime.py --report "$runtime_smoke" + +echo +echo "==> [4/5] appender smoke (PR-B2 contract regression)" +PYTHONPATH=. python3 scripts/smoke_grpc_appender.py --report "$appender_smoke" + +echo +echo "==> [5/5] generator smoke (PR-B3 new)" +PYTHONPATH=. python3 scripts/smoke_grpc_generator.py --report "$generator_smoke" + +echo +echo "==> Done." +echo " Generator tests : $gen_report" +echo " gRPC tests : $grpc_report" +echo " Runtime smoke : $runtime_smoke" +echo " Appender smoke : $appender_smoke" +echo " Generator smoke : $generator_smoke" +echo +echo "Next:" +echo " git add $out_dir/pr-b3-mac-*" +echo " git commit -m 'Mac M4 review evidence for PR-B3'" +echo " git push" diff --git a/scripts/smoke_grpc_generator.py b/scripts/smoke_grpc_generator.py new file mode 100755 index 00000000..831e4dd1 --- /dev/null +++ b/scripts/smoke_grpc_generator.py @@ -0,0 +1,363 @@ +"""End-to-end smoke for the PR-B3 Generate server-streaming RPC. + +Spins up a real ``grpc.aio.Server`` with both the AppendTokens +coordinator (PR-B2) and the Generation coordinator (PR-B3) wired +in, and walks the Generate scenarios this PR ships: + + 1. CreateSession -> success + 2. AppendTokens (cold prefill) -> success + 3. Generate (max_tokens=3, no EOS) -> 3 token_id frames + done(MAX_TOKENS) + 4. Generate (max_tokens=10, EOS in token 6) -> 1 token + done(EOS) (deterministic by FakeVerifier) + 5. Generate (no AppendTokens prior) -> INVALID_ARGUMENT + 6. Generate (max_tokens=0) -> INVALID_ARGUMENT + 7. Generate (temperature=0.7) -> INVALID_ARGUMENT + 8. Generate (unknown session) -> NOT_FOUND + 9. Generate (large prefill -> truncated state) -> 1 truncated frame + tokens + done + 10. Generate (no coordinator wired) -> UNIMPLEMENTED + +Each step prints one JSON-Lines record with expected vs observed +outcome. Exit code 0 iff all 10 scenarios match. + +Same review-affordance pattern as PR-B1 (smoke_grpc_runtime.py) and +PR-B2 (smoke_grpc_appender.py). + +Usage:: + + PYTHONPATH=. python3 scripts/smoke_grpc_generator.py \\ + --report results/platform-tests/grpc-generator-smoke-$(date +%s).json +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import platform +import sys +import time +import traceback +from dataclasses import asdict, dataclass, field +from typing import Any, AsyncIterator, Optional + +import grpc + +from inference_engine.server.grpc_app import RuntimeServiceServicer +from inference_engine.server.proto_gen.kakeya.v1 import ( + runtime_pb2, + runtime_pb2_grpc, +) +from inference_engine.session import ( + AppendTokensCoordinator, + GenerationCoordinator, + SessionStore, +) + +from tests.inference_engine.session.test_coordinator import FakeVerifier + + +@dataclass +class StepResult: + step: str + expected: str + observed: str + passed: bool + detail: dict = field(default_factory=dict) + elapsed_ms: float = 0.0 + + def asline(self) -> str: + return json.dumps(asdict(self), separators=(",", ":")) + + +async def _serve( + store: SessionStore, + append_coord: Optional[AppendTokensCoordinator] = None, + gen_coord: Optional[GenerationCoordinator] = None, +) -> AsyncIterator[tuple[runtime_pb2_grpc.RuntimeServiceStub, grpc.aio.Server, int]]: + server = grpc.aio.server() + runtime_pb2_grpc.add_RuntimeServiceServicer_to_server( + RuntimeServiceServicer( + store, + append_coordinator=append_coord, + generation_coordinator=gen_coord, + ), + server, + ) + port = server.add_insecure_port("127.0.0.1:0") + await server.start() + channel = grpc.aio.insecure_channel(f"127.0.0.1:{port}") + stub = runtime_pb2_grpc.RuntimeServiceStub(channel) + try: + yield stub, server, port + finally: + await channel.close() + await server.stop(grace=0.1) + + +async def _step(name: str, expected: str, body) -> StepResult: + t0 = time.perf_counter() + try: + observed, detail = await body() + passed = observed == expected + except Exception as exc: # noqa: BLE001 + observed = f"unexpected exception: {type(exc).__name__}" + detail = {"traceback": traceback.format_exc()} + passed = False + elapsed_ms = (time.perf_counter() - t0) * 1000 + return StepResult( + step=name, expected=expected, observed=observed, + passed=passed, detail=detail, elapsed_ms=round(elapsed_ms, 2), + ) + + +async def run_smoke(verbose: bool = True) -> list[StepResult]: + results: list[StepResult] = [] + + def emit(r: StepResult) -> None: + results.append(r) + if verbose: + print(r.asline(), flush=True) + + # ----- Server #1: full coordinator wiring (Append + Generate) ----- + fv = FakeVerifier() + store = SessionStore(capacity=4, cache_inspector=fv) + append_coord = AppendTokensCoordinator(store, fv) + gen_coord = GenerationCoordinator(store, fv) + s_ctx = _serve(store, append_coord, gen_coord).__aiter__() + stub, _, _ = await s_ctx.__anext__() + try: + async def _step1(): + r = await stub.CreateSession(runtime_pb2.CreateSessionRequest()) + return ("ok", {"session_id": r.session_id}) + emit(await _step("CreateSession", "ok", _step1)) + sid = results[-1].detail["session_id"] + + async def _step2(): + r = await stub.AppendTokens( + runtime_pb2.AppendTokensRequest( + session_id=sid, token_ids=[1, 2, 3], + ), + ) + return ("ok", {"history_length": r.history_length}) + emit(await _step("AppendTokens (cold prefill)", "ok", _step2)) + + async def _step3(): + frames = [] + async for f in stub.Generate( + runtime_pb2.GenerateRequest(session_id=sid, max_tokens=3), + ): + frames.append(f.WhichOneof("payload")) + done = frames.count("done") + tokens = frames.count("token_id") + return ("ok", { + "frames": frames, + "tokens_emitted": tokens, + "done_frames": done, + }) + emit(await _step( + "Generate (max_tokens=3, no EOS)", "ok", _step3, + )) + + # Step 4: EOS scenario. FakeVerifier's _logits_for produces argmax + # = sum(history[-3:]) % 16; with history [1,2,3] -> argmax=6. + # Create fresh session with eos=[6]; first generated token is 6. + async def _step4(): + r_create = await stub.CreateSession( + runtime_pb2.CreateSessionRequest(eos_token_ids=[6]), + ) + sid2 = r_create.session_id + await stub.AppendTokens( + runtime_pb2.AppendTokensRequest( + session_id=sid2, token_ids=[1, 2, 3], + ), + ) + tokens = [] + stop_reason_name = None + async for f in stub.Generate( + runtime_pb2.GenerateRequest(session_id=sid2, max_tokens=10), + ): + kind = f.WhichOneof("payload") + if kind == "token_id": + tokens.append(f.token_id) + elif kind == "done": + stop_reason_name = ( + runtime_pb2.GenerateDone.StopReason.Name( + f.done.stop_reason, + ) + ) + return ("ok", { + "tokens": tokens, + "stop_reason": stop_reason_name, + }) + emit(await _step( + "Generate (EOS triggers, max_tokens=10)", "ok", _step4, + )) + + # Step 5: Generate without prior AppendTokens. + async def _step5(): + r_create = await stub.CreateSession(runtime_pb2.CreateSessionRequest()) + try: + async for _f in stub.Generate( + runtime_pb2.GenerateRequest( + session_id=r_create.session_id, max_tokens=1, + ), + ): + return ("ok", {}) # would be a bug + return ("ok", {}) + except grpc.aio.AioRpcError as e: + return (e.code().name, {"details": e.details()[:80]}) + emit(await _step( + "Generate (no AppendTokens prior)", "INVALID_ARGUMENT", _step5, + )) + + # Step 6: max_tokens=0 + async def _step6(): + try: + async for _f in stub.Generate( + runtime_pb2.GenerateRequest(session_id=sid, max_tokens=0), + ): + return ("ok", {}) + return ("ok", {}) + except grpc.aio.AioRpcError as e: + return (e.code().name, {"details": e.details()[:60]}) + emit(await _step( + "Generate (max_tokens=0)", "INVALID_ARGUMENT", _step6, + )) + + # Step 7: temperature=0.7 (non-greedy rejected in v0.3) + async def _step7(): + try: + async for _f in stub.Generate( + runtime_pb2.GenerateRequest( + session_id=sid, max_tokens=1, temperature=0.7, + ), + ): + return ("ok", {}) + return ("ok", {}) + except grpc.aio.AioRpcError as e: + return (e.code().name, {"details": e.details()[:60]}) + emit(await _step( + "Generate (temperature=0.7, non-greedy rejected)", + "INVALID_ARGUMENT", _step7, + )) + + # Step 8: unknown session + async def _step8(): + try: + async for _f in stub.Generate( + runtime_pb2.GenerateRequest( + session_id="sess-nonexistent", max_tokens=1, + ), + ): + return ("ok", {}) + return ("ok", {}) + except grpc.aio.AioRpcError as e: + return (e.code().name, {"details": e.details()[:60]}) + emit(await _step( + "Generate (unknown session)", "NOT_FOUND", _step8, + )) + + # Step 9: large prefill -> truncated state + async def _step9(): + r_create = await stub.CreateSession(runtime_pb2.CreateSessionRequest()) + await stub.AppendTokens( + runtime_pb2.AppendTokensRequest( + session_id=r_create.session_id, + token_ids=[10, 20, 30, 40, 50, 60, 70, 80], + ), + ) + frame_kinds = [] + truncated_dropped = None + async for f in stub.Generate( + runtime_pb2.GenerateRequest( + session_id=r_create.session_id, max_tokens=2, + ), + ): + kind = f.WhichOneof("payload") + frame_kinds.append(kind) + if kind == "truncated": + truncated_dropped = f.truncated.dropped_token_count + return ("ok", { + "frames": frame_kinds, + "dropped_token_count": truncated_dropped, + }) + emit(await _step( + "Generate (truncated state -> truncated frame)", "ok", _step9, + )) + finally: + try: + await s_ctx.__anext__() + except StopAsyncIteration: + pass + + # ----- Server #2: no generation_coordinator wired ----- + fv2 = FakeVerifier() + store2 = SessionStore(capacity=2) + s2_ctx = _serve(store2, None, None).__aiter__() + stub2, _, _ = await s2_ctx.__anext__() + try: + async def _step10(): + try: + async for _f in stub2.Generate( + runtime_pb2.GenerateRequest( + session_id="any", max_tokens=1, + ), + ): + return ("ok", {}) + return ("ok", {}) + except grpc.aio.AioRpcError as e: + return (e.code().name, {"details": e.details()[:80]}) + emit(await _step( + "Generate (no coordinator wired)", "UNIMPLEMENTED", _step10, + )) + finally: + try: + await s2_ctx.__anext__() + except StopAsyncIteration: + pass + + return results + + +def _summary(results: list[StepResult]) -> dict[str, Any]: + return { + "schema_version": 1, + "kind": "grpc_generator_smoke", + "host": { + "platform": platform.platform(), + "machine": platform.machine(), + "python": platform.python_version(), + "grpc": grpc.__version__, + }, + "steps_total": len(results), + "steps_passed": sum(r.passed for r in results), + "steps_failed": sum(not r.passed for r in results), + "all_passed": all(r.passed for r in results), + "steps": [asdict(r) for r in results], + } + + +def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--report", type=str, default=None) + p.add_argument("--quiet", action="store_true") + return p.parse_args(argv) + + +def main(argv: Optional[list[str]] = None) -> int: + args = _parse_args(argv) + results = asyncio.run(run_smoke(verbose=not args.quiet)) + summary = _summary(results) + print(json.dumps( + {"summary": {k: v for k, v in summary.items() if k not in ("steps", "host")}}, + separators=(",", ":"), + )) + print(json.dumps({"host": summary["host"]}, separators=(",", ":"))) + if args.report: + with open(args.report, "w", encoding="utf-8") as fh: + json.dump(summary, fh, indent=2) + print(f"report written: {args.report}") + return 0 if summary["all_passed"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/inference_engine/server/test_grpc_app.py b/tests/inference_engine/server/test_grpc_app.py index ae0cf759..fa59b74c 100644 --- a/tests/inference_engine/server/test_grpc_app.py +++ b/tests/inference_engine/server/test_grpc_app.py @@ -485,9 +485,15 @@ def append_tokens(self, session_id, token_ids): # --------------------------------------------------------------------------- -async def test_generate_returns_unimplemented(grpc_pair): - """Generate lands in PR-B3. The framework default UNIMPLEMENTED - must hold until that PR explicitly overrides the method.""" +# --------------------------------------------------------------------------- +# Generate (PR-B3) — wired via GenerationCoordinator + FakeVerifier +# --------------------------------------------------------------------------- + + +async def test_generate_returns_unimplemented_when_no_coordinator(grpc_pair): + """Servicer constructed without a GenerationCoordinator (the + PR-B1 / PR-B2 default) keeps the framework UNIMPLEMENTED for + Generate. Regression contract.""" stub, _, _ = grpc_pair with pytest.raises(grpc.aio.AioRpcError) as exc_info: async for _event in stub.Generate( @@ -497,6 +503,350 @@ async def test_generate_returns_unimplemented(grpc_pair): assert exc_info.value.code() == grpc.StatusCode.UNIMPLEMENTED +@pytest_asyncio.fixture +async def grpc_pair_with_generator() -> AsyncIterator[ + tuple[ + runtime_pb2_grpc.RuntimeServiceStub, + SessionStore, + FakeVerifier, + grpc.aio.Server, + ] +]: + """gRPC pair with both AppendTokens and Generate coordinators + wired (so we can prep a session via AppendTokens, then call + Generate against it).""" + from inference_engine.session import ( + AppendTokensCoordinator, + GenerationCoordinator, + ) + + fv = FakeVerifier() + store = SessionStore(capacity=4, cache_inspector=fv) + append_coord = AppendTokensCoordinator(store, fv) + gen_coord = GenerationCoordinator(store, fv) + server = grpc.aio.server() + runtime_pb2_grpc.add_RuntimeServiceServicer_to_server( + RuntimeServiceServicer( + store, + append_coordinator=append_coord, + generation_coordinator=gen_coord, + ), + server, + ) + port = server.add_insecure_port("127.0.0.1:0") + await server.start() + channel = grpc.aio.insecure_channel(f"127.0.0.1:{port}") + stub = runtime_pb2_grpc.RuntimeServiceStub(channel) + try: + yield stub, store, fv, server + finally: + await channel.close() + await server.stop(grace=0.1) + + +async def _prep_session(stub, token_ids=(1, 2, 3)): + """Create + prefill a session, return its session_id.""" + create = await stub.CreateSession(runtime_pb2.CreateSessionRequest()) + await stub.AppendTokens( + runtime_pb2.AppendTokensRequest( + session_id=create.session_id, token_ids=list(token_ids), + ), + ) + return create.session_id + + +async def test_generate_streams_tokens_then_done(grpc_pair_with_generator): + stub, _, _, _ = grpc_pair_with_generator + sid = await _prep_session(stub) + events = [] + async for resp in stub.Generate( + runtime_pb2.GenerateRequest(session_id=sid, max_tokens=3), + ): + events.append(resp) + # Three token frames followed by one done frame. + payload_kinds = [r.WhichOneof("payload") for r in events] + assert payload_kinds == ["token_id", "token_id", "token_id", "done"] + done = events[-1].done + assert done.stop_reason == runtime_pb2.GenerateDone.STOP_REASON_MAX_TOKENS + assert done.generated_token_count == 3 + assert done.prefill_duration_seconds == 0.0 + + +async def test_generate_eos_stops_with_eos_stop_reason( + grpc_pair_with_generator, +): + stub, store, _, _ = grpc_pair_with_generator + # Pre-load history that makes the first argmax = 6 (FakeVerifier's + # _logits_for hashes recent 3 tokens to argmax = sum % 16). + create = await stub.CreateSession( + runtime_pb2.CreateSessionRequest(eos_token_ids=[6]), + ) + await stub.AppendTokens( + runtime_pb2.AppendTokensRequest( + session_id=create.session_id, token_ids=[1, 2, 3], + ), + ) + events = [] + async for resp in stub.Generate( + runtime_pb2.GenerateRequest( + session_id=create.session_id, max_tokens=10, + ), + ): + events.append(resp) + payload_kinds = [r.WhichOneof("payload") for r in events] + assert payload_kinds == ["token_id", "done"] + assert events[0].token_id == 6 + assert events[-1].done.stop_reason == \ + runtime_pb2.GenerateDone.STOP_REASON_EOS + + +async def test_generate_history_truncated_emitted(grpc_pair_with_generator): + stub, store, _, _ = grpc_pair_with_generator + # FakeVerifier's default budget is sink+window = 2+4 = 6. + # Prefill 8 tokens so we're in truncated state at start of Generate. + create = await stub.CreateSession(runtime_pb2.CreateSessionRequest()) + await stub.AppendTokens( + runtime_pb2.AppendTokensRequest( + session_id=create.session_id, + token_ids=[10, 20, 30, 40, 50, 60, 70, 80], + ), + ) + events = [] + async for resp in stub.Generate( + runtime_pb2.GenerateRequest( + session_id=create.session_id, max_tokens=2, + ), + ): + events.append(resp) + payload_kinds = [r.WhichOneof("payload") for r in events] + # First frame is truncated, then tokens, then done. + assert payload_kinds[0] == "truncated" + assert events[0].truncated.dropped_token_count == 2 # 8 - 6 + # Tokens follow. + assert payload_kinds[1:3] == ["token_id", "token_id"] + assert payload_kinds[3] == "done" + + +async def test_generate_unknown_session_returns_not_found( + grpc_pair_with_generator, +): + stub, _, _, _ = grpc_pair_with_generator + with pytest.raises(grpc.aio.AioRpcError) as exc_info: + async for _resp in stub.Generate( + runtime_pb2.GenerateRequest( + session_id="sess-nonexistent", max_tokens=1, + ), + ): + pass # pragma: no cover - stream raises before yielding + assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND + + +async def test_generate_no_history_returns_invalid_argument( + grpc_pair_with_generator, +): + """Session created but no AppendTokens preceded — Generate has + no prefill state to start from. Must surface INVALID_ARGUMENT, + not crash on argmax of uninitialized logits.""" + stub, _, _, _ = grpc_pair_with_generator + create = await stub.CreateSession(runtime_pb2.CreateSessionRequest()) + with pytest.raises(grpc.aio.AioRpcError) as exc_info: + async for _resp in stub.Generate( + runtime_pb2.GenerateRequest( + session_id=create.session_id, max_tokens=1, + ), + ): + pass # pragma: no cover + assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT + assert "AppendTokens must precede" in exc_info.value.details() + + +async def test_generate_max_tokens_zero_returns_invalid_argument( + grpc_pair_with_generator, +): + stub, _, _, _ = grpc_pair_with_generator + sid = await _prep_session(stub) + with pytest.raises(grpc.aio.AioRpcError) as exc_info: + async for _resp in stub.Generate( + runtime_pb2.GenerateRequest(session_id=sid, max_tokens=0), + ): + pass # pragma: no cover + assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT + + +async def test_generate_temperature_nonzero_returns_invalid_argument( + grpc_pair_with_generator, +): + stub, _, _, _ = grpc_pair_with_generator + sid = await _prep_session(stub) + with pytest.raises(grpc.aio.AioRpcError) as exc_info: + async for _resp in stub.Generate( + runtime_pb2.GenerateRequest( + session_id=sid, max_tokens=1, temperature=0.7, + ), + ): + pass # pragma: no cover + assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT + + +async def test_generate_seed_is_accepted(grpc_pair_with_generator): + """Seed must be accepted on the wire (proto3 optional uint64). + In greedy mode it's ignored; the run must complete normally.""" + stub, _, _, _ = grpc_pair_with_generator + sid = await _prep_session(stub) + events = [] + async for resp in stub.Generate( + runtime_pb2.GenerateRequest( + session_id=sid, max_tokens=2, seed=42, + ), + ): + events.append(resp) + assert any(r.WhichOneof("payload") == "done" for r in events) + + +async def test_generate_invariant_violation_returns_failed_precondition(): + """An INV-1 violation during a generation step must surface as + FAILED_PRECONDITION, not INTERNAL.""" + from inference_engine.session import ( + AppendTokensCoordinator, + GenerationCoordinator, + ) + + fv = FakeVerifier() + store = SessionStore(capacity=2, cache_inspector=fv) + append_coord = AppendTokensCoordinator(store, fv) + gen_coord = GenerationCoordinator(store, fv) + server = grpc.aio.server() + runtime_pb2_grpc.add_RuntimeServiceServicer_to_server( + RuntimeServiceServicer( + store, + append_coordinator=append_coord, + generation_coordinator=gen_coord, + ), + server, + ) + port = server.add_insecure_port("127.0.0.1:0") + await server.start() + channel = grpc.aio.insecure_channel(f"127.0.0.1:{port}") + stub = runtime_pb2_grpc.RuntimeServiceStub(channel) + try: + # Set up a valid session via honest AppendTokens. + create = await stub.CreateSession(runtime_pb2.CreateSessionRequest()) + await stub.AppendTokens( + runtime_pb2.AppendTokensRequest( + session_id=create.session_id, token_ids=[1, 2, 3], + ), + ) + # Now make k_seq_length lie so the FIRST generation step's + # INV-1 check fires. + fv.k_seq_length = lambda session: 999 + with pytest.raises(grpc.aio.AioRpcError) as exc_info: + async for _resp in stub.Generate( + runtime_pb2.GenerateRequest( + session_id=create.session_id, max_tokens=1, + ), + ): + pass # pragma: no cover - stream aborts mid-way + assert exc_info.value.code() == grpc.StatusCode.FAILED_PRECONDITION + assert "INV-1" in exc_info.value.details() + finally: + await channel.close() + await server.stop(grace=0.1) + + +async def test_create_grpc_server_accepts_generation_coordinator(): + """The factory must accept the new keyword and plumb it through.""" + from inference_engine.session import GenerationCoordinator + + fv = FakeVerifier() + store = SessionStore(capacity=2) + coord = GenerationCoordinator(store, fv) + server = create_grpc_server( + session_store=store, + generation_coordinator=coord, + config=GrpcServerConfig(bind_address="127.0.0.1:0"), + ) + assert server is not None + + +async def test_generate_cancellation_emits_cancelled_done(): + """Drive the Servicer's Generate directly with a fake gRPC context + that flips ``cancelled()`` to True after the first event. The + servicer must: + + 1. Yield the first TokenEvent normally. + 2. On the next loop turn, observe context.cancelled() == True. + 3. Emit a final GenerateDone(STOP_REASON_CANCELLED) frame and + return — without continuing to generate. + + Direct-invocation test rather than through-the-channel: the + real-channel cancellation closes the connection from the client + side, so the server-emitted CANCELLED frame is observable only + in-process. This test exercises the server-side branch. + """ + from inference_engine.session import ( + AppendTokensCoordinator, + GenerationCoordinator, + ) + + fv = FakeVerifier() + store = SessionStore(capacity=1, cache_inspector=fv) + append_coord = AppendTokensCoordinator(store, fv) + gen_coord = GenerationCoordinator(store, fv) + sess = store.create_session() + append_coord.append_tokens(sess.session_id, [1, 2, 3]) + + servicer = RuntimeServiceServicer( + store, + append_coordinator=append_coord, + generation_coordinator=gen_coord, + ) + + class _FakeContext: + """Minimal stand-in for grpc.aio.ServicerContext. + + Tracks how many times ``cancelled()`` has been polled; flips + to True after the first poll so the very first iteration of + the servicer's loop yields a TokenEvent normally and the + second iteration observes the cancellation. ``abort`` is not + used in this happy-path-of-cancellation test. + """ + def __init__(self) -> None: + self._polls = 0 + self.poll_history: list[bool] = [] + + def cancelled(self) -> bool: + self._polls += 1 + verdict = self._polls > 1 + self.poll_history.append(verdict) + return verdict + + async def abort(self, code, details): # pragma: no cover + raise AssertionError( + f"abort should not be called: {code} {details!r}", + ) + + ctx = _FakeContext() + request = runtime_pb2.GenerateRequest( + session_id=sess.session_id, max_tokens=10, + ) + + events = [] + async for resp in servicer.Generate(request, ctx): + events.append(resp) + + # First frame is a token, then CANCELLED done — no more. + assert len(events) == 2 + assert events[0].WhichOneof("payload") == "token_id" + assert events[1].WhichOneof("payload") == "done" + assert events[1].done.stop_reason == \ + runtime_pb2.GenerateDone.STOP_REASON_CANCELLED + assert events[1].done.generated_token_count == 1 + # cancelled() polled twice: once on first iteration (returned + # False, allowed token to flow), once on second (returned True, + # tripped CANCELLED branch). + assert ctx.poll_history == [False, True] + + # --------------------------------------------------------------------------- # create_grpc_server factory + GrpcServerConfig # --------------------------------------------------------------------------- diff --git a/tests/inference_engine/session/test_generator.py b/tests/inference_engine/session/test_generator.py new file mode 100644 index 00000000..fce16ffe --- /dev/null +++ b/tests/inference_engine/session/test_generator.py @@ -0,0 +1,433 @@ +"""Unit tests for :mod:`inference_engine.session.generator` (PR-B3). + +Coverage target: 100% on ``inference_engine/session/generator.py``. + +Test strategy mirrors :mod:`tests.inference_engine.session.test_coordinator`: +the dispatch + state-mirroring + error-mapping logic is tested with +the deterministic :class:`FakeVerifier` (Linux-runnable, no model +weights). Real Qwen3 verifier integration lives under +:mod:`tests.core` (Mac-only) and runs on the §9 Mac M4 gate. +""" + +from __future__ import annotations + +import pytest +import torch + +from inference_engine.session import ( + AppendTokensCoordinator, + DoneEvent, + GenerationCoordinator, + HistoryTruncatedEvent, + InvariantViolation, + SessionNotFoundError, + SessionStore, + STOP_REASON_EOS, + STOP_REASON_MAX_TOKENS, + TokenEvent, +) + +# Reuse the FakeVerifier from PR-B2's test module rather than +# re-defining it. It already mirrors the real verifier's mutation +# contract (sink+window trim in commit_or_truncate, parallel-sequence +# growth in forward_block, deterministic logits). +from tests.inference_engine.session.test_coordinator import FakeVerifier + + +def _build( + *, + sink_size: int = 2, + window_size: int = 4, + eos_token_ids=(), + initial_tokens=(1, 2, 3), +): + """Construct (store, fv, gen_coord, session) ready for Generate. + + Runs an AppendTokens via the PR-B2 coordinator first so the + session has prefilled state — Generate against an empty session + is a documented ValueError, tested separately. + """ + fv = FakeVerifier( + sink_size=sink_size, window_size=window_size, vocab_size=16, + ) + store = SessionStore(capacity=2, cache_inspector=fv) + append_coord = AppendTokensCoordinator(store, fv) + gen_coord = GenerationCoordinator(store, fv) + sess = store.create_session(eos_token_ids=eos_token_ids) + if initial_tokens: + append_coord.append_tokens(sess.session_id, list(initial_tokens)) + return store, fv, gen_coord, sess + + +# --------------------------------------------------------------------------- +# Greedy dispatch + happy path +# --------------------------------------------------------------------------- + + +class TestGreedyHappyPath: + def test_yields_token_then_done(self): + store, fv, coord, sess = _build() + events = list(coord.generate(sess.session_id, max_tokens=1)) + assert len(events) == 2 + assert isinstance(events[0], TokenEvent) + assert isinstance(events[1], DoneEvent) + assert events[1].stop_reason == STOP_REASON_MAX_TOKENS + assert events[1].generated_token_count == 1 + + def test_max_tokens_caps_token_emission(self): + store, fv, coord, sess = _build() + events = list(coord.generate(sess.session_id, max_tokens=3)) + token_events = [e for e in events if isinstance(e, TokenEvent)] + done_events = [e for e in events if isinstance(e, DoneEvent)] + assert len(token_events) == 3 + assert len(done_events) == 1 + assert done_events[0].stop_reason == STOP_REASON_MAX_TOKENS + assert done_events[0].generated_token_count == 3 + + def test_done_is_terminal_and_unique(self): + store, fv, coord, sess = _build() + events = list(coord.generate(sess.session_id, max_tokens=2)) + # Done event is exactly one and is the last. + done_indices = [ + i for i, e in enumerate(events) if isinstance(e, DoneEvent) + ] + assert len(done_indices) == 1 + assert done_indices[0] == len(events) - 1 + + def test_done_includes_total_seconds(self): + store, fv, coord, sess = _build() + events = list(coord.generate(sess.session_id, max_tokens=1)) + done = events[-1] + assert isinstance(done, DoneEvent) + assert done.total_seconds >= 0.0 + # PR-B3 has no separate prefill phase. + assert done.prefill_seconds == 0.0 + + +class TestGreedyAdvancesVerifier: + def test_each_token_advances_position_by_one(self): + store, fv, coord, sess = _build() + pos_before = fv.next_global_position + list(coord.generate(sess.session_id, max_tokens=4)) + assert fv.next_global_position == pos_before + 4 + + def test_session_history_grows(self): + store, fv, coord, sess = _build() + list(coord.generate(sess.session_id, max_tokens=3)) + assert len(sess.history_token_ids) == 3 + 3 # initial + generated + + def test_session_cached_token_sequence_mirrors_verifier(self): + store, fv, coord, sess = _build(sink_size=2, window_size=4) + list(coord.generate(sess.session_id, max_tokens=10)) + assert sess.cached_token_sequence == fv.cached_token_sequence + + def test_each_token_calls_forward_then_commit(self): + store, fv, coord, sess = _build() + fv.call_log.clear() + list(coord.generate(sess.session_id, max_tokens=2)) + # 2 (forward_block, commit_or_truncate) pairs. + kinds = [c[0] for c in fv.call_log] + assert kinds == [ + "forward_block", "commit_or_truncate", + "forward_block", "commit_or_truncate", + ] + + +# --------------------------------------------------------------------------- +# EOS handling +# --------------------------------------------------------------------------- + + +class TestEos: + def test_eos_token_terminates_with_eos_stop_reason(self): + # FakeVerifier._logits_for hashes recent tokens to an argmax. + # We pre-load history that makes the next argmax a known + # token, then put that token in eos_token_ids. + # The FakeVerifier formula: argmax = sum(history[-3:]) % 16. + # With initial=[1, 2, 3], next argmax = 6. + store, fv, coord, sess = _build( + initial_tokens=(1, 2, 3), eos_token_ids=(6,), + ) + events = list(coord.generate(sess.session_id, max_tokens=10)) + token_events = [e for e in events if isinstance(e, TokenEvent)] + done_events = [e for e in events if isinstance(e, DoneEvent)] + # Exactly one TokenEvent, then Done with EOS. + assert len(token_events) == 1 + assert token_events[0].token_id == 6 + assert done_events[0].stop_reason == STOP_REASON_EOS + assert done_events[0].generated_token_count == 1 + + def test_no_eos_runs_to_max_tokens(self): + # Use a token id that cannot be produced (vocab size 16; eos + # set to 99 cannot match any argmax). + store, fv, coord, sess = _build(eos_token_ids=(99,)) + events = list(coord.generate(sess.session_id, max_tokens=4)) + done_events = [e for e in events if isinstance(e, DoneEvent)] + assert done_events[0].stop_reason == STOP_REASON_MAX_TOKENS + + def test_empty_eos_set_runs_to_max_tokens(self): + store, fv, coord, sess = _build(eos_token_ids=()) + events = list(coord.generate(sess.session_id, max_tokens=2)) + done = next(e for e in events if isinstance(e, DoneEvent)) + assert done.stop_reason == STOP_REASON_MAX_TOKENS + + +# --------------------------------------------------------------------------- +# HistoryTruncated event +# --------------------------------------------------------------------------- + + +class TestHistoryTruncated: + def test_emitted_at_start_when_already_truncated(self): + # sink+window = 2+4 = 6 capacity. Append 8 tokens → cache + # holds 6, history holds 8 → already truncated state. + store, fv, coord, sess = _build( + sink_size=2, window_size=4, + initial_tokens=(10, 20, 30, 40, 50, 60, 70, 80), + ) + events = list(coord.generate(sess.session_id, max_tokens=1)) + # First non-token event should be HistoryTruncated, before + # any TokenEvent. + assert isinstance(events[0], HistoryTruncatedEvent) + assert events[0].dropped_token_count == 8 - 6 # 2 dropped + # A TokenEvent must follow before Done. + assert isinstance(events[1], TokenEvent) + + def test_not_emitted_when_under_capacity(self): + # sink+window = 6; initial = 3 tokens; cache == history. + store, fv, coord, sess = _build( + sink_size=2, window_size=4, initial_tokens=(1, 2, 3), + ) + events = list(coord.generate(sess.session_id, max_tokens=2)) + # No HistoryTruncated event present. + assert not any( + isinstance(e, HistoryTruncatedEvent) for e in events + ) + + def test_at_most_one_per_call(self): + # Even after generation pushes well past the boundary, only + # one HistoryTruncated per Generate call (per the proto + # contract: "Emitted at most once per Generate call"). + store, fv, coord, sess = _build( + sink_size=2, window_size=4, + initial_tokens=(10, 20, 30, 40, 50, 60, 70, 80), + ) + events = list(coord.generate(sess.session_id, max_tokens=10)) + truncated_events = [ + e for e in events if isinstance(e, HistoryTruncatedEvent) + ] + assert len(truncated_events) == 1 + + +# --------------------------------------------------------------------------- +# Validation: max_tokens, sampling params, no AppendTokens prior +# --------------------------------------------------------------------------- + + +class TestValidation: + def test_max_tokens_zero_rejected(self): + store, fv, coord, sess = _build() + with pytest.raises(ValueError, match="max_tokens must be >= 1"): + list(coord.generate(sess.session_id, max_tokens=0)) + + def test_max_tokens_negative_rejected(self): + store, fv, coord, sess = _build() + with pytest.raises(ValueError, match="max_tokens must be >= 1"): + list(coord.generate(sess.session_id, max_tokens=-3)) + + def test_temperature_nonzero_rejected(self): + store, fv, coord, sess = _build() + with pytest.raises(ValueError, match="greedy"): + list(coord.generate( + sess.session_id, max_tokens=1, temperature=0.5, + )) + + def test_temperature_zero_accepted(self): + store, fv, coord, sess = _build() + # Temperature=0 is greedy's no-op default; accept silently. + events = list(coord.generate( + sess.session_id, max_tokens=1, temperature=0.0, + )) + assert any(isinstance(e, TokenEvent) for e in events) + + def test_top_p_set_rejected(self): + store, fv, coord, sess = _build() + with pytest.raises(ValueError, match="top_p"): + list(coord.generate( + sess.session_id, max_tokens=1, top_p=0.9, + )) + + def test_top_k_other_than_one_rejected(self): + store, fv, coord, sess = _build() + with pytest.raises(ValueError, match="top_k"): + list(coord.generate( + sess.session_id, max_tokens=1, top_k=50, + )) + + def test_top_k_one_accepted(self): + store, fv, coord, sess = _build() + events = list(coord.generate( + sess.session_id, max_tokens=1, top_k=1, + )) + assert any(isinstance(e, TokenEvent) for e in events) + + def test_seed_accepted_and_ignored_in_greedy(self): + store, fv, coord, sess = _build() + # Seed shouldn't affect greedy output. Two runs with + # different seeds must produce identical token streams. + store_a, fv_a, coord_a, sess_a = _build() + store_b, fv_b, coord_b, sess_b = _build() + events_a = [ + e for e in coord_a.generate( + sess_a.session_id, max_tokens=4, seed=1, + ) + if isinstance(e, TokenEvent) + ] + events_b = [ + e for e in coord_b.generate( + sess_b.session_id, max_tokens=4, seed=999, + ) + if isinstance(e, TokenEvent) + ] + assert [e.token_id for e in events_a] == [ + e.token_id for e in events_b + ] + + def test_no_appendtokens_first_rejected(self): + # Session created but no AppendTokens called — no prefill, + # so next_token_logits is meaningless. Reject loudly. + fv = FakeVerifier() + store = SessionStore(capacity=1, cache_inspector=fv) + coord = GenerationCoordinator(store, fv) + sess = store.create_session() + with pytest.raises(ValueError, match="AppendTokens must precede"): + list(coord.generate(sess.session_id, max_tokens=1)) + + def test_unknown_session_raises_session_not_found(self): + store, fv, coord, _ = _build() + with pytest.raises(SessionNotFoundError): + list(coord.generate("sess-unknown", max_tokens=1)) + + +# --------------------------------------------------------------------------- +# INV-1 / INV-2 propagation through Generate +# --------------------------------------------------------------------------- + + +class TestInvariants: + def test_inv1_violation_propagates(self): + # Drive AppendTokens with an honest inspector, then patch the + # inspector to lie just before Generate. The lying inspector + # makes the first generation step's INV-1 check fail because + # session.cached_token_sequence (mirrored from verifier) won't + # match the lie's reported k_seq_length. + fv = FakeVerifier() + store = SessionStore(capacity=1, cache_inspector=fv) + append_coord = AppendTokensCoordinator(store, fv) + gen_coord = GenerationCoordinator(store, fv) + sess = store.create_session() + # AppendTokens with the honest FakeVerifier — works. + append_coord.append_tokens(sess.session_id, [1, 2, 3]) + # Now monkey-patch the inspector to lie. Note: SessionStore's + # _assert_inv1 calls self._cache_inspector.k_seq_length(session), + # which dispatches to the patched bound method. + fv.k_seq_length = lambda session: 999 # type: ignore[assignment] + with pytest.raises(InvariantViolation) as exc: + list(gen_coord.generate(sess.session_id, max_tokens=1)) + assert exc.value.kind == "1" + with pytest.raises(SessionNotFoundError): + store.get_session(sess.session_id) + + def test_inv2_violation_propagates(self): + # AppendTokens uses verifier.prefill, NOT commit_or_truncate, + # so the FIRST commit_or_truncate the Verifier sees is from + # the first generation step. Trip the regress on call #1. + class _RegressingVerifier(FakeVerifier): + def __init__(self): + super().__init__() + self._calls = 0 + + def commit_or_truncate(self, *, forwarded, accepted): + super().commit_or_truncate( + forwarded=forwarded, accepted=accepted, + ) + self._calls += 1 + if self._calls == 1: # first generation step's commit + self.next_global_position = 0 # regress + + fv = _RegressingVerifier() + store = SessionStore(capacity=1, cache_inspector=fv) + append_coord = AppendTokensCoordinator(store, fv) + gen_coord = GenerationCoordinator(store, fv) + sess = store.create_session() + append_coord.append_tokens(sess.session_id, [1, 2, 3]) + with pytest.raises(InvariantViolation) as exc: + list(gen_coord.generate(sess.session_id, max_tokens=1)) + assert exc.value.kind == "2" + + +# --------------------------------------------------------------------------- +# Determinism (INV-3 byte-exact under greedy) +# --------------------------------------------------------------------------- + + +class TestDeterminism: + def test_two_runs_with_same_history_produce_same_tokens(self): + # INV-3 byte-exact at the GenerationCoordinator level: two + # parallel sessions with identical history produce identical + # token sequences under greedy decoding. + store_a, fv_a, coord_a, sess_a = _build( + initial_tokens=(7, 11, 13, 17, 19), + ) + store_b, fv_b, coord_b, sess_b = _build( + initial_tokens=(7, 11, 13, 17, 19), + ) + tokens_a = [ + e.token_id + for e in coord_a.generate(sess_a.session_id, max_tokens=8) + if isinstance(e, TokenEvent) + ] + tokens_b = [ + e.token_id + for e in coord_b.generate(sess_b.session_id, max_tokens=8) + if isinstance(e, TokenEvent) + ] + assert tokens_a == tokens_b + + +# --------------------------------------------------------------------------- +# Constructor / event types +# --------------------------------------------------------------------------- + + +class TestConstructorAndEventDataclasses: + def test_constructor_stores_references(self): + fv = FakeVerifier() + store = SessionStore(capacity=1, cache_inspector=fv) + coord = GenerationCoordinator(store, fv) + # Coordinator accepts the references; we verify by exercising. + sess = store.create_session() + AppendTokensCoordinator(store, fv).append_tokens( + sess.session_id, [1], + ) + events = list(coord.generate(sess.session_id, max_tokens=1)) + assert any(isinstance(e, TokenEvent) for e in events) + + def test_token_event_is_frozen(self): + e = TokenEvent(token_id=5) + with pytest.raises(Exception): # FrozenInstanceError + e.token_id = 6 # type: ignore[misc] + + def test_history_truncated_event_is_frozen(self): + e = HistoryTruncatedEvent(dropped_token_count=3) + with pytest.raises(Exception): + e.dropped_token_count = 4 # type: ignore[misc] + + def test_done_event_is_frozen(self): + e = DoneEvent( + stop_reason=STOP_REASON_MAX_TOKENS, + generated_token_count=1, + prefill_seconds=0.0, total_seconds=0.0, + ) + with pytest.raises(Exception): + e.generated_token_count = 2 # type: ignore[misc] From 16196be51b5c5b2bcc1cbb347f579357120fceba Mon Sep 17 00:00:00 2001 From: fluffy314 Date: Mon, 1 Jun 2026 22:21:13 +0800 Subject: [PATCH 2/3] Mac M4 review evidence for PR-B3 Co-authored-by: Cursor --- ...ac-generator-tests-1780323650.coverage.xml | 82 ++++++ .../pr-b3-mac-generator-tests-1780323650.json | 209 ++++++++++++++ ...3-mac-generator-tests-1780323650.junit.xml | 1 + ...b3-mac-grpc-appender-smoke-1780323650.json | 132 +++++++++ ...3-mac-grpc-generator-smoke-1780323650.json | 132 +++++++++ ...-b3-mac-grpc-runtime-smoke-1780323650.json | 129 +++++++++ ...-b3-mac-grpc-tests-1780323650.coverage.xml | 107 ++++++++ .../pr-b3-mac-grpc-tests-1780323650.json | 257 ++++++++++++++++++ .../pr-b3-mac-grpc-tests-1780323650.junit.xml | 1 + 9 files changed, 1050 insertions(+) create mode 100644 results/platform-tests/pr-b3-mac-generator-tests-1780323650.coverage.xml create mode 100644 results/platform-tests/pr-b3-mac-generator-tests-1780323650.json create mode 100644 results/platform-tests/pr-b3-mac-generator-tests-1780323650.junit.xml create mode 100644 results/platform-tests/pr-b3-mac-grpc-appender-smoke-1780323650.json create mode 100644 results/platform-tests/pr-b3-mac-grpc-generator-smoke-1780323650.json create mode 100644 results/platform-tests/pr-b3-mac-grpc-runtime-smoke-1780323650.json create mode 100644 results/platform-tests/pr-b3-mac-grpc-tests-1780323650.coverage.xml create mode 100644 results/platform-tests/pr-b3-mac-grpc-tests-1780323650.json create mode 100644 results/platform-tests/pr-b3-mac-grpc-tests-1780323650.junit.xml diff --git a/results/platform-tests/pr-b3-mac-generator-tests-1780323650.coverage.xml b/results/platform-tests/pr-b3-mac-generator-tests-1780323650.coverage.xml new file mode 100644 index 00000000..96790ba5 --- /dev/null +++ b/results/platform-tests/pr-b3-mac-generator-tests-1780323650.coverage.xml @@ -0,0 +1,82 @@ + + + + + + /Users/fluffy314/Documents/Kakeya-LLM-Inference-engine + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/results/platform-tests/pr-b3-mac-generator-tests-1780323650.json b/results/platform-tests/pr-b3-mac-generator-tests-1780323650.json new file mode 100644 index 00000000..d2933a19 --- /dev/null +++ b/results/platform-tests/pr-b3-mac-generator-tests-1780323650.json @@ -0,0 +1,209 @@ +{ + "schema_version": 1, + "kind": "pr_b3_mac_generator_tests", + "host": { + "platform": "macOS-26.5-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "python": "3.13.12" + }, + "junit": { + "tests": 31, + "failures": 0, + "errors": 0, + "skipped": 0, + "cases": [ + { + "classname": "tests.inference_engine.session.test_generator.TestGreedyHappyPath", + "name": "test_yields_token_then_done", + "time": 0.001, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestGreedyHappyPath", + "name": "test_max_tokens_caps_token_emission", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestGreedyHappyPath", + "name": "test_done_is_terminal_and_unique", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestGreedyHappyPath", + "name": "test_done_includes_total_seconds", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestGreedyAdvancesVerifier", + "name": "test_each_token_advances_position_by_one", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestGreedyAdvancesVerifier", + "name": "test_session_history_grows", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestGreedyAdvancesVerifier", + "name": "test_session_cached_token_sequence_mirrors_verifier", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestGreedyAdvancesVerifier", + "name": "test_each_token_calls_forward_then_commit", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestEos", + "name": "test_eos_token_terminates_with_eos_stop_reason", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestEos", + "name": "test_no_eos_runs_to_max_tokens", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestEos", + "name": "test_empty_eos_set_runs_to_max_tokens", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestHistoryTruncated", + "name": "test_emitted_at_start_when_already_truncated", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestHistoryTruncated", + "name": "test_not_emitted_when_under_capacity", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestHistoryTruncated", + "name": "test_at_most_one_per_call", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestValidation", + "name": "test_max_tokens_zero_rejected", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestValidation", + "name": "test_max_tokens_negative_rejected", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestValidation", + "name": "test_temperature_nonzero_rejected", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestValidation", + "name": "test_temperature_zero_accepted", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestValidation", + "name": "test_top_p_set_rejected", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestValidation", + "name": "test_top_k_other_than_one_rejected", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestValidation", + "name": "test_top_k_one_accepted", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestValidation", + "name": "test_seed_accepted_and_ignored_in_greedy", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestValidation", + "name": "test_no_appendtokens_first_rejected", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestValidation", + "name": "test_unknown_session_raises_session_not_found", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestInvariants", + "name": "test_inv1_violation_propagates", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestInvariants", + "name": "test_inv2_violation_propagates", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestDeterminism", + "name": "test_two_runs_with_same_history_produce_same_tokens", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestConstructorAndEventDataclasses", + "name": "test_constructor_stores_references", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestConstructorAndEventDataclasses", + "name": "test_token_event_is_frozen", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestConstructorAndEventDataclasses", + "name": "test_history_truncated_event_is_frozen", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.session.test_generator.TestConstructorAndEventDataclasses", + "name": "test_done_event_is_frozen", + "time": 0.0, + "outcome": "passed" + } + ] + }, + "coverage": { + "line_rate": 1.0, + "branch_rate": 0.0, + "lines_covered": 63, + "lines_valid": 63 + } +} \ No newline at end of file diff --git a/results/platform-tests/pr-b3-mac-generator-tests-1780323650.junit.xml b/results/platform-tests/pr-b3-mac-generator-tests-1780323650.junit.xml new file mode 100644 index 00000000..a05a9094 --- /dev/null +++ b/results/platform-tests/pr-b3-mac-generator-tests-1780323650.junit.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/results/platform-tests/pr-b3-mac-grpc-appender-smoke-1780323650.json b/results/platform-tests/pr-b3-mac-grpc-appender-smoke-1780323650.json new file mode 100644 index 00000000..d841e6cb --- /dev/null +++ b/results/platform-tests/pr-b3-mac-grpc-appender-smoke-1780323650.json @@ -0,0 +1,132 @@ +{ + "schema_version": 1, + "kind": "grpc_appender_smoke", + "host": { + "platform": "macOS-26.5-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "python": "3.13.12", + "grpc": "1.81.0" + }, + "steps_total": 10, + "steps_passed": 10, + "steps_failed": 0, + "all_passed": true, + "steps": [ + { + "step": "CreateSession", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "session_id": "sess-bde5b331375e4a76a82af3d4f91306af" + }, + "elapsed_ms": 0.99 + }, + { + "step": "AppendTokens (cold prefill)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "history_length": 3, + "verifier_calls": [ + "prefill" + ] + }, + "elapsed_ms": 0.48 + }, + { + "step": "GetSessionInfo (after cold)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "history_length": 3, + "inv1_violations": 0, + "inv2_violations": 0 + }, + "elapsed_ms": 0.32 + }, + { + "step": "AppendTokens (incremental)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "history_length": 5, + "verifier_calls_total": [ + "prefill", + "forward_block", + "commit_or_truncate" + ] + }, + "elapsed_ms": 0.34 + }, + { + "step": "GetSessionInfo (after incremental)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "history_length": 5 + }, + "elapsed_ms": 0.31 + }, + { + "step": "AppendTokens (empty list, no-op)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "history_length": 5, + "verifier_calls_after_empty": [ + "prefill", + "forward_block", + "commit_or_truncate" + ] + }, + "elapsed_ms": 0.29 + }, + { + "step": "AppendTokens (unknown session)", + "expected": "NOT_FOUND", + "observed": "NOT_FOUND", + "passed": true, + "detail": { + "details": "session_id 'sess-nonexistent' not found" + }, + "elapsed_ms": 0.32 + }, + { + "step": "AppendTokens (after CloseSession)", + "expected": "NOT_FOUND", + "observed": "NOT_FOUND", + "passed": true, + "detail": { + "details": "session_id 'sess-bde5b331375e4a76a82af3d4f91306af' not found" + }, + "elapsed_ms": 0.3 + }, + { + "step": "Generate (PR-B3, still UNIMPLEMENTED)", + "expected": "UNIMPLEMENTED", + "observed": "UNIMPLEMENTED", + "passed": true, + "detail": { + "phase": "PR-B3" + }, + "elapsed_ms": 0.35 + }, + { + "step": "AppendTokens (INV-1 violation)", + "expected": "FAILED_PRECONDITION", + "observed": "FAILED_PRECONDITION", + "passed": true, + "detail": { + "details": "INV-1 violation in session 'sess-89aefa3e276144388ce057c6fbf27c80': cached_token_sequence length (3) != K/V tensor seque", + "kind": "INV-1" + }, + "elapsed_ms": 0.3 + } + ] +} \ No newline at end of file diff --git a/results/platform-tests/pr-b3-mac-grpc-generator-smoke-1780323650.json b/results/platform-tests/pr-b3-mac-grpc-generator-smoke-1780323650.json new file mode 100644 index 00000000..13c6f0a8 --- /dev/null +++ b/results/platform-tests/pr-b3-mac-grpc-generator-smoke-1780323650.json @@ -0,0 +1,132 @@ +{ + "schema_version": 1, + "kind": "grpc_generator_smoke", + "host": { + "platform": "macOS-26.5-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "python": "3.13.12", + "grpc": "1.81.0" + }, + "steps_total": 10, + "steps_passed": 10, + "steps_failed": 0, + "all_passed": true, + "steps": [ + { + "step": "CreateSession", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "session_id": "sess-c9d9f77c02d4498a975518cb6a61319a" + }, + "elapsed_ms": 0.98 + }, + { + "step": "AppendTokens (cold prefill)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "history_length": 3 + }, + "elapsed_ms": 0.48 + }, + { + "step": "Generate (max_tokens=3, no EOS)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "frames": [ + "token_id", + "token_id", + "token_id", + "done" + ], + "tokens_emitted": 3, + "done_frames": 1 + }, + "elapsed_ms": 0.63 + }, + { + "step": "Generate (EOS triggers, max_tokens=10)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "tokens": [ + 6 + ], + "stop_reason": "STOP_REASON_EOS" + }, + "elapsed_ms": 1.01 + }, + { + "step": "Generate (no AppendTokens prior)", + "expected": "INVALID_ARGUMENT", + "observed": "INVALID_ARGUMENT", + "passed": true, + "detail": { + "details": "session has no history yet; AppendTokens must precede Generate (the first token'" + }, + "elapsed_ms": 0.57 + }, + { + "step": "Generate (max_tokens=0)", + "expected": "INVALID_ARGUMENT", + "observed": "INVALID_ARGUMENT", + "passed": true, + "detail": { + "details": "max_tokens must be >= 1, got 0" + }, + "elapsed_ms": 0.33 + }, + { + "step": "Generate (temperature=0.7, non-greedy rejected)", + "expected": "INVALID_ARGUMENT", + "observed": "INVALID_ARGUMENT", + "passed": true, + "detail": { + "details": "v0.3 supports only greedy decoding; temperature must be 0 or" + }, + "elapsed_ms": 0.31 + }, + { + "step": "Generate (unknown session)", + "expected": "NOT_FOUND", + "observed": "NOT_FOUND", + "passed": true, + "detail": { + "details": "session_id 'sess-nonexistent' not found" + }, + "elapsed_ms": 0.34 + }, + { + "step": "Generate (truncated state -> truncated frame)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "frames": [ + "truncated", + "token_id", + "token_id", + "done" + ], + "dropped_token_count": 2 + }, + "elapsed_ms": 1.5 + }, + { + "step": "Generate (no coordinator wired)", + "expected": "UNIMPLEMENTED", + "observed": "UNIMPLEMENTED", + "passed": true, + "detail": { + "details": "Generate not configured on this Servicer (coordinator not provided)" + }, + "elapsed_ms": 0.72 + } + ] +} \ No newline at end of file diff --git a/results/platform-tests/pr-b3-mac-grpc-runtime-smoke-1780323650.json b/results/platform-tests/pr-b3-mac-grpc-runtime-smoke-1780323650.json new file mode 100644 index 00000000..ece46352 --- /dev/null +++ b/results/platform-tests/pr-b3-mac-grpc-runtime-smoke-1780323650.json @@ -0,0 +1,129 @@ +{ + "schema_version": 1, + "kind": "grpc_runtime_smoke", + "host": { + "platform": "macOS-26.5-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "python": "3.13.12", + "grpc": "1.81.0" + }, + "steps_total": 10, + "steps_passed": 10, + "steps_failed": 0, + "all_passed": true, + "steps": [ + { + "step": "CreateSession", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "session_id": "sess-7304326ad3a7452a8aef5c4f877042df", + "port": 54787 + }, + "elapsed_ms": 1.03 + }, + { + "step": "GetSessionInfo (initial)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "history_length": 0, + "kv_live_bytes": 0, + "inv1_violations": 0, + "inv2_violations": 0, + "idle_seconds": 0.0004 + }, + "elapsed_ms": 0.33 + }, + { + "step": "CloseSession", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "final_history_length": 0 + }, + "elapsed_ms": 0.29 + }, + { + "step": "CloseSession (double-close)", + "expected": "NOT_FOUND", + "observed": "NOT_FOUND", + "passed": true, + "detail": { + "details": "session_id 'sess-7304326ad3a7452a8aef5c4f877042df' not found" + }, + "elapsed_ms": 0.36 + }, + { + "step": "GetSessionInfo (after close)", + "expected": "NOT_FOUND", + "observed": "NOT_FOUND", + "passed": true, + "detail": { + "details": "session_id 'sess-7304326ad3a7452a8aef5c4f877042df' not found" + }, + "elapsed_ms": 0.3 + }, + { + "step": "AppendTokens (PR-B2)", + "expected": "UNIMPLEMENTED", + "observed": "UNIMPLEMENTED", + "passed": true, + "detail": { + "phase": "PR-B2" + }, + "elapsed_ms": 0.29 + }, + { + "step": "Generate (PR-B3)", + "expected": "UNIMPLEMENTED", + "observed": "UNIMPLEMENTED", + "passed": true, + "detail": { + "phase": "PR-B3" + }, + "elapsed_ms": 0.34 + }, + { + "step": "CreateSession (eos + client_label)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "session_id": "sess-6458d33e57bd446e9e720c43a29cc191", + "eos_token_ids_recorded": [ + 7, + 11, + 13 + ], + "client_label_recorded": "smoke-demo" + }, + "elapsed_ms": 0.34 + }, + { + "step": "CreateSession (pool slab #1 / 1)", + "expected": "ok", + "observed": "ok", + "passed": true, + "detail": { + "session_id": "sess-7c0f52bf475e480193b78ca8c9740878" + }, + "elapsed_ms": 0.77 + }, + { + "step": "CreateSession (pool exhausted)", + "expected": "RESOURCE_EXHAUSTED", + "observed": "RESOURCE_EXHAUSTED", + "passed": true, + "detail": { + "details": "slab pool exhausted: all 1 slabs in use; admission control must reject or queue this session", + "pool_in_use": 1, + "pool_available": 0 + }, + "elapsed_ms": 0.49 + } + ] +} \ No newline at end of file diff --git a/results/platform-tests/pr-b3-mac-grpc-tests-1780323650.coverage.xml b/results/platform-tests/pr-b3-mac-grpc-tests-1780323650.coverage.xml new file mode 100644 index 00000000..759a0162 --- /dev/null +++ b/results/platform-tests/pr-b3-mac-grpc-tests-1780323650.coverage.xml @@ -0,0 +1,107 @@ + + + + + + /Users/fluffy314/Documents/Kakeya-LLM-Inference-engine + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/results/platform-tests/pr-b3-mac-grpc-tests-1780323650.json b/results/platform-tests/pr-b3-mac-grpc-tests-1780323650.json new file mode 100644 index 00000000..22da2706 --- /dev/null +++ b/results/platform-tests/pr-b3-mac-grpc-tests-1780323650.json @@ -0,0 +1,257 @@ +{ + "schema_version": 1, + "kind": "pr_b3_mac_grpc_tests", + "host": { + "platform": "macOS-26.5-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "python": "3.13.12" + }, + "junit": { + "tests": 39, + "failures": 0, + "errors": 0, + "skipped": 0, + "cases": [ + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_create_session_returns_server_issued_id", + "time": 0.021, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_create_session_records_eos_token_ids", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_create_session_records_client_label", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_create_session_default_eos_is_empty_tuple", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_create_session_pool_exhausted_returns_resource_exhausted", + "time": 0.003, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_close_session_returns_final_history_length", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_close_session_returns_zero_for_empty_session", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_close_session_unknown_id_returns_not_found", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_close_session_double_close_returns_not_found", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_get_session_info_initial_state", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_get_session_info_reflects_history_growth", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_get_session_info_reflects_kv_live_bytes_when_pool_present", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_get_session_info_unknown_id_returns_not_found", + "time": 0.001, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_get_session_info_after_close_returns_not_found", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_append_tokens_returns_unimplemented_when_no_coordinator", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_append_tokens_first_call_triggers_prefill", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_append_tokens_subsequent_call_triggers_incremental", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_append_tokens_unknown_session_returns_not_found", + "time": 0.001, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_append_tokens_invariant_violation_returns_failed_precondition", + "time": 0.003, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_append_tokens_value_error_returns_invalid_argument", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_generate_returns_unimplemented_when_no_coordinator", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_generate_streams_tokens_then_done", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_generate_eos_stops_with_eos_stop_reason", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_generate_history_truncated_emitted", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_generate_unknown_session_returns_not_found", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_generate_no_history_returns_invalid_argument", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_generate_max_tokens_zero_returns_invalid_argument", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_generate_temperature_nonzero_returns_invalid_argument", + "time": 0.003, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_generate_seed_is_accepted", + "time": 0.002, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_generate_invariant_violation_returns_failed_precondition", + "time": 0.005, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_create_grpc_server_accepts_generation_coordinator", + "time": 0.001, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_generate_cancellation_emits_cancelled_done", + "time": 0.001, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_default_bind_address_is_loopback", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_grpc_server_config_defaults", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_grpc_server_config_is_frozen", + "time": 0.0, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_create_grpc_server_default_config_binds_default_address", + "time": 0.001, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_create_grpc_server_with_no_config_uses_defaults", + "time": 0.001, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_create_grpc_server_with_max_concurrent_rpcs", + "time": 0.001, + "outcome": "passed" + }, + { + "classname": "tests.inference_engine.server.test_grpc_app", + "name": "test_create_grpc_server_accepts_append_coordinator", + "time": 0.001, + "outcome": "passed" + } + ] + }, + "coverage": { + "line_rate": 1.0, + "branch_rate": 0.0, + "lines_covered": 88, + "lines_valid": 88 + } +} \ No newline at end of file diff --git a/results/platform-tests/pr-b3-mac-grpc-tests-1780323650.junit.xml b/results/platform-tests/pr-b3-mac-grpc-tests-1780323650.junit.xml new file mode 100644 index 00000000..1882be1d --- /dev/null +++ b/results/platform-tests/pr-b3-mac-grpc-tests-1780323650.junit.xml @@ -0,0 +1 @@ + \ No newline at end of file From 09d91eeb587294e4c5df4323de0b6625a999b038 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 14:54:42 +0000 Subject: [PATCH 3/3] PR-B4 (ADR 0008 Phase B): Python SDK \u2014 kakeya.Client + kakeya.Session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks on PR-B3 (#46). When this PR is merged, PR-B3 lands along with it. Public API matches the ADR 0008 \u00a73.1 example exactly: from kakeya import Client with Client('localhost:50051') as client: with client.create_session(eos_token_ids=[151645]) as session: session.append([10, 20, 30]) for token_id in session.generate(max_tokens=64): print(token_id) Surface (sdks/python/kakeya/): client.py (Client) - .address / .closed properties - .create_session(eos_token_ids, client_label) -> Session - .close() / context manager - Wraps grpc.insecure_channel + RuntimeServiceStub (sync) session.py (Session, SessionInfo) - .session_id / .closed - .last_stop_reason / .last_generated_token_count / .last_prefill_duration_seconds / .last_total_duration_seconds / .last_history_truncated_dropped (populated by .generate) - .append(token_ids) -> int - .generate(max_tokens, seed, temperature, top_p, top_k) -> Iterator[int] - .info() -> SessionInfo - .close() / context manager - SessionClosedError on local-side reuse after close() errors.py (typed exception hierarchy) - KakeyaError (base, carries .rpc_code) - SessionNotFoundError <- gRPC NOT_FOUND - InvalidArgumentError <- gRPC INVALID_ARGUMENT - InvariantViolationError <- gRPC FAILED_PRECONDITION - ResourceExhaustedError <- gRPC RESOURCE_EXHAUSTED - UnimplementedError <- gRPC UNIMPLEMENTED - RpcCancelledError <- gRPC CANCELLED - SessionClosedError <- client-side, never crosses the wire Sync vs async: PR-B4 ships sync only (grpc.insecure_channel). The ADR \u00a73.1 example is sync, and the v0.3 target audience (REPL, scripts, agent harnesses on a single Mac) prefers sync. An async API can be added in a later PR without breaking this surface; the runtime is identical (grpc.aio.server is wire-compatible with sync clients). Tokenization: per ADR \u00a72.4 / \u00a73.4, the SDK ships ZERO chat-template logic. The runtime treats token ids as opaque integers; rendering messages to tokens lives in sdks/python/examples/ (a future PR). Tests (tests/sdk/python/): test_errors.py (20 tests) Every gRPC StatusCode -> typed-exception mapping verified. Plus the catch-all path for unknown codes, the .rpc_code carry, and the SessionClosedError no-rpc-code variant. test_client.py (13 tests) Construction, address property, closed flag, idempotent close, context manager, create_session with all argument combinations, RESOURCE_EXHAUSTED -> ResourceExhaustedError end-to-end. test_session.py (33 tests) All 5 RPC methods, all error mappings, last_* metadata fields, HistoryTruncated -> .last_history_truncated_dropped, sampling param validation (greedy-only contract surfaces as InvalidArgumentError), context manager, double-close idempotency, SessionInfo dataclass standalone construction + repr. All 66 tests run against a real grpc.aio runtime spun up in a background thread (see tests/sdk/python/conftest.py). The fixture creates the gRPC server INSIDE the worker thread's loop (not the main thread) to avoid 'Future attached to a different loop' from grpcio's internals; shutdown uses server.wait_for_termination() so the loop can exit cleanly without a cancellation tantrum. CI changes (.github/workflows/ci.yaml): - PYTHONPATH gains sdks/python so 'import kakeya' resolves without pip-install (consistent with the rest of the repo's PYTHONPATH-based layout). - tests/sdk/python/ added to pytest paths. - --cov=kakeya added to the coverage gate. - package-import-smoke imports kakeya, kakeya.client, kakeya.session, kakeya.errors (regression catches an accidentally-broken __init__). Mac M4 reviewer aid (\u00a79 carve-out applies — Linux-only path): scripts/review_pr_b4_on_mac.sh Runs SDK pytest + 100% coverage gate, plus all three prior PR-B1/B2/B3 smokes for regression. Produces 4 JSON artifacts under results/platform-tests/. Local verification (Linux VM, py3.12): Linux CI gate: 695 passed (was 629 + 66 new). Coverage 100.00% on 1694 stmts (was 1521 + 173 new = sdk surface). Reviewer script end-to-end: SDK tests : 66 tests, cov=173/173=100% Runtime smoke : 10/10 Appender smoke : 10/10 Generator smoke : 10/10 Next PR after merge: PR-B5 (\u00a76.2): TypeScript SDK under sdks/typescript/. Targets Node.js 20+ / Electron 30+ / Bun 1.1+. Generates stubs from proto/kakeya/v1/runtime.proto via protoc-gen-ts_proto. Linux-only path; \u00a79 carve-out continues to apply. Co-authored-by: FluffyAIcode --- .github/workflows/ci.yaml | 14 +- scripts/review_pr_b4_on_mac.sh | 146 ++++++++++++ sdks/python/kakeya/__init__.py | 57 +++++ sdks/python/kakeya/client.py | 131 +++++++++++ sdks/python/kakeya/errors.py | 133 +++++++++++ sdks/python/kakeya/session.py | 302 ++++++++++++++++++++++++ tests/sdk/python/__init__.py | 0 tests/sdk/python/conftest.py | 202 ++++++++++++++++ tests/sdk/python/test_client.py | 137 +++++++++++ tests/sdk/python/test_errors.py | 131 +++++++++++ tests/sdk/python/test_session.py | 388 +++++++++++++++++++++++++++++++ 11 files changed, 1640 insertions(+), 1 deletion(-) create mode 100755 scripts/review_pr_b4_on_mac.sh create mode 100644 sdks/python/kakeya/__init__.py create mode 100644 sdks/python/kakeya/client.py create mode 100644 sdks/python/kakeya/errors.py create mode 100644 sdks/python/kakeya/session.py create mode 100644 tests/sdk/python/__init__.py create mode 100644 tests/sdk/python/conftest.py create mode 100644 tests/sdk/python/test_client.py create mode 100644 tests/sdk/python/test_errors.py create mode 100644 tests/sdk/python/test_session.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 260c387b..6248f249 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -65,7 +65,12 @@ jobs: - name: Run platform-neutral test suite with 100% coverage env: - PYTHONPATH: . + # sdks/python is the on-tree location of the Python SDK + # (per ADR 0008 §3.1). Adding it to PYTHONPATH lets the + # tests import `kakeya` without having to pip-install the + # SDK as a separate package — both layouts work, and the + # PYTHONPATH route avoids a setuptools build step in CI. + PYTHONPATH: .:sdks/python run: | pytest \ tests/inference_engine/server/ \ @@ -73,6 +78,7 @@ jobs: tests/inference_engine/scheduler/ \ tests/inference_engine/pipeline/ \ tests/inference_engine/session/ \ + tests/sdk/python/ \ tests/training/repr_align/ \ tests/backends/mlx/test_env.py \ --cov=inference_engine.server \ @@ -80,6 +86,7 @@ jobs: --cov=inference_engine.scheduler \ --cov=inference_engine.pipeline \ --cov=inference_engine.session \ + --cov=kakeya \ --cov=training.repr_align \ --cov-report=term \ --cov-report=xml:coverage.xml \ @@ -142,6 +149,11 @@ jobs: import inference_engine.server.grpc_app; \ import inference_engine.server.proto_gen.kakeya.v1.runtime_pb2; \ import inference_engine.server.proto_gen.kakeya.v1.runtime_pb2_grpc; \ + import sys; sys.path.insert(0, 'sdks/python'); \ + import kakeya; \ + import kakeya.client; \ + import kakeya.session; \ + import kakeya.errors; \ import inference_engine.proposer; \ import inference_engine.proposer.sparse_logits; \ import inference_engine.backends.mlx.env; \ diff --git a/scripts/review_pr_b4_on_mac.sh b/scripts/review_pr_b4_on_mac.sh new file mode 100755 index 00000000..dbf8ee74 --- /dev/null +++ b/scripts/review_pr_b4_on_mac.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Mac M4 review aid for PR-B4 (ADR 0008 Phase B, Python SDK). +# +# Per ADR 0008 §9, PR-B4 is a Linux-only path (the SDK is pure +# Python wrapping `grpc.insecure_channel` + the generated stubs; +# no MLX runtime code). The §9 carve-out applies, so this script +# is a review affordance — not a mandatory §9 report. Reviewers +# who want hardware-level evidence on Apple Silicon run it; the +# binding gate is Linux CI. +# +# Produces 4 artifacts under results/platform-tests/: +# +# 1. pr-b4-mac-sdk-tests-.json +# pytest tests/sdk/python/ with 100% coverage on +# sdks/python/kakeya/*. 66 tests total covering Client, +# Session, errors, and end-to-end gRPC streaming through +# the SDK. +# +# 2. pr-b4-mac-grpc-runtime-smoke-.json +# Regression smoke: smoke_grpc_runtime.py (PR-B1 contract). +# +# 3. pr-b4-mac-grpc-appender-smoke-.json +# Regression smoke: smoke_grpc_appender.py (PR-B2 contract). +# +# 4. pr-b4-mac-grpc-generator-smoke-.json +# Regression smoke: smoke_grpc_generator.py (PR-B3 contract). +# +# Same `coverage run -m pytest` + `--include` filter pattern as +# review_pr_b3_on_mac.sh — sidesteps the Python 3.13 / coverage / +# torch race documented in commits 9cb1c56 + 9d1a250. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +stamp="$(date +%s)" +out_dir="results/platform-tests" +mkdir -p "$out_dir" + +sdk_junit="$out_dir/pr-b4-mac-sdk-tests-${stamp}.junit.xml" +sdk_cov="$out_dir/pr-b4-mac-sdk-tests-${stamp}.coverage.xml" +sdk_report="$out_dir/pr-b4-mac-sdk-tests-${stamp}.json" + +runtime_smoke="$out_dir/pr-b4-mac-grpc-runtime-smoke-${stamp}.json" +appender_smoke="$out_dir/pr-b4-mac-grpc-appender-smoke-${stamp}.json" +generator_smoke="$out_dir/pr-b4-mac-grpc-generator-smoke-${stamp}.json" + + +_summarize_pytest() { + # $1 junit $2 coverage $3 out $4 kind label + PYTHONPATH=.:sdks/python python3 - "$1" "$2" "$3" "$4" <<'PY' +import json, platform, sys, xml.etree.ElementTree as ET +junit_path, cov_path, out_path, kind = sys.argv[1:5] +jr = ET.parse(junit_path).getroot() + +# Aggregate counts from elements; pytest puts counts on +# the inner element, not on the wrapper. Same fix as +# commit 9d1a250. +testsuites = list(jr.iter("testsuite")) +total_tests = sum(int(ts.get("tests", "0")) for ts in testsuites) +total_failures = sum(int(ts.get("failures", "0")) for ts in testsuites) +total_errors = sum(int(ts.get("errors", "0")) for ts in testsuites) +total_skipped = sum(int(ts.get("skipped", "0")) for ts in testsuites) + +cases = [] +for tc in jr.iter("testcase"): + cases.append({ + "classname": tc.get("classname"), + "name": tc.get("name"), + "time": float(tc.get("time", 0.0)), + "outcome": ( + "failed" if tc.find("failure") is not None + else "errored" if tc.find("error") is not None + else "skipped" if tc.find("skipped") is not None + else "passed" + ), + }) + +cov_root = ET.parse(cov_path).getroot() +report = { + "schema_version": 1, + "kind": kind, + "host": { + "platform": platform.platform(), + "machine": platform.machine(), + "python": platform.python_version(), + }, + "junit": { + "tests": total_tests, + "failures": total_failures, + "errors": total_errors, + "skipped": total_skipped, + "cases": cases, + }, + "coverage": { + "line_rate": float(cov_root.get("line-rate", "0.0")), + "branch_rate": float(cov_root.get("branch-rate", "0.0")), + "lines_covered": int(cov_root.get("lines-covered", "0")), + "lines_valid": int(cov_root.get("lines-valid", "0")), + }, +} +with open(out_path, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2) +print(f" -> {out_path}") +PY +} + + +echo "==> [1/4] SDK unit + integration tests" +PYTHONPATH=.:sdks/python python3 -m coverage erase +PYTHONPATH=.:sdks/python python3 -m coverage run \ + -m pytest tests/sdk/python/ \ + --junitxml="$sdk_junit" -v +python3 -m coverage report \ + --include='sdks/python/kakeya/*' \ + --fail-under=100 -m +python3 -m coverage xml \ + --include='sdks/python/kakeya/*' \ + -o "$sdk_cov" +_summarize_pytest "$sdk_junit" "$sdk_cov" "$sdk_report" \ + "pr_b4_mac_sdk_tests" + +echo +echo "==> [2/4] runtime smoke (PR-B1 regression)" +PYTHONPATH=. python3 scripts/smoke_grpc_runtime.py --report "$runtime_smoke" + +echo +echo "==> [3/4] appender smoke (PR-B2 regression)" +PYTHONPATH=. python3 scripts/smoke_grpc_appender.py --report "$appender_smoke" + +echo +echo "==> [4/4] generator smoke (PR-B3 regression)" +PYTHONPATH=. python3 scripts/smoke_grpc_generator.py --report "$generator_smoke" + +echo +echo "==> Done." +echo " SDK tests : $sdk_report" +echo " Runtime smoke : $runtime_smoke" +echo " Appender smoke : $appender_smoke" +echo " Generator smoke : $generator_smoke" +echo +echo "Next:" +echo " git add $out_dir/pr-b4-mac-*" +echo " git commit -m 'Mac M4 review evidence for PR-B4'" +echo " git push" diff --git a/sdks/python/kakeya/__init__.py b/sdks/python/kakeya/__init__.py new file mode 100644 index 00000000..098d4e80 --- /dev/null +++ b/sdks/python/kakeya/__init__.py @@ -0,0 +1,57 @@ +"""Kakeya Python SDK — public API surface (PR-B4 of ADR 0008). + +Two top-level types power the entire surface: + + * :class:`Client` — connection to a Kakeya RuntimeService. + * :class:`Session` — handle to one server-side session. + +Plus a typed exception hierarchy rooted at :class:`KakeyaError` +(see :mod:`kakeya.errors`) that maps every gRPC status code from +the runtime to a Python class. + +Example:: + + from kakeya import Client + + with Client("localhost:50051") as client: + with client.create_session(eos_token_ids=[151645]) as session: + session.append([10, 20, 30]) + for token_id in session.generate(max_tokens=64): + print(token_id) + +Tokenization is intentionally NOT part of the SDK core — per +ADR 0008 §2.4 / §3.4, the runtime treats token ids as opaque +integers; rendering messages to tokens is the application's +responsibility (or an opt-in helper that lives in +``sdks/python/examples/``). Importing :mod:`transformers` and +calling ``apply_chat_template`` is one valid path; using a custom +serializer is another. The SDK takes no position. +""" + +from kakeya.client import DEFAULT_ADDRESS, Client +from kakeya.errors import ( + InvalidArgumentError, + InvariantViolationError, + KakeyaError, + ResourceExhaustedError, + RpcCancelledError, + SessionClosedError, + SessionNotFoundError, + UnimplementedError, +) +from kakeya.session import Session, SessionInfo + +__all__ = [ + "Client", + "DEFAULT_ADDRESS", + "InvalidArgumentError", + "InvariantViolationError", + "KakeyaError", + "ResourceExhaustedError", + "RpcCancelledError", + "Session", + "SessionClosedError", + "SessionInfo", + "SessionNotFoundError", + "UnimplementedError", +] diff --git a/sdks/python/kakeya/client.py b/sdks/python/kakeya/client.py new file mode 100644 index 00000000..54492c0f --- /dev/null +++ b/sdks/python/kakeya/client.py @@ -0,0 +1,131 @@ +"""Kakeya Python SDK — :class:`Client` (PR-B4 of ADR 0008 Phase B). + +A thin sync wrapper around ``grpc.insecure_channel`` + the +generated ``RuntimeServiceStub``. Public surface matches the +ADR 0008 §3.1 example:: + + client = Client("localhost:50051") + session = client.create_session(eos_token_ids=[151645]) + session.append([10, 20, 30]) + for token_id in session.generate(max_tokens=64): + print(token_id) + session.close() + client.close() + +Or as a context manager:: + + with Client("localhost:50051") as client: + with client.create_session() as session: + ... + +The SDK is sync because (a) the ADR §3.1 example is sync, (b) the +target audience for the Python SDK in v0.3 is REPL / scripts / +agent harnesses where async adds friction, and (c) v0.3 is +single-tenant (``max_concurrent=1``) so the perf cost of a sync +call blocking is irrelevant. An async API can be added in a +follow-up PR without breaking this surface. + +The runtime can be either sync- or async-backed (``grpc.aio.server`` +or ``grpc.server``); the wire protocol is identical and our SDK +talks to both. +""" + +from __future__ import annotations + +from typing import Iterable, Optional, TYPE_CHECKING + +import grpc + +from inference_engine.server.proto_gen.kakeya.v1 import ( + runtime_pb2, + runtime_pb2_grpc, +) +from kakeya.errors import _wrap_grpc_error + +if TYPE_CHECKING: + from kakeya.session import Session + + +DEFAULT_ADDRESS = "localhost:50051" +"""Default gRPC bind address for a local Kakeya runtime, matching +``inference_engine.server.grpc_app.DEFAULT_BIND_ADDRESS``.""" + + +class Client: + """A connection to a Kakeya RuntimeService. + + Construction opens a gRPC channel; the channel is closed by + :meth:`close` (also invoked by ``__exit__`` when used as a + context manager). The connection is lazy — no RPC is made until + a method like :meth:`create_session` is called. + """ + + def __init__( + self, + address: str = DEFAULT_ADDRESS, + *, + channel_options: Optional[list] = None, + ) -> None: + self._address = address + self._channel = grpc.insecure_channel( + address, options=channel_options or [], + ) + self._stub = runtime_pb2_grpc.RuntimeServiceStub(self._channel) + self._closed = False + + @property + def address(self) -> str: + return self._address + + @property + def closed(self) -> bool: + return self._closed + + def create_session( + self, + *, + eos_token_ids: Iterable[int] = (), + client_label: str = "", + ) -> "Session": + """Create a new session on the runtime. + + Returns a :class:`~kakeya.session.Session` bound to this + client. The session is alive until ``session.close()`` is + called or the runtime evicts it; until then the + ``session.session_id`` is the stable handle. + + Raises: + * :class:`~kakeya.errors.ResourceExhaustedError` if the + runtime's slab pool is full. + * :class:`~kakeya.errors.KakeyaError` (base) for any + other gRPC failure. + """ + from kakeya.session import Session # local import: cycle avoidance + + request = runtime_pb2.CreateSessionRequest( + eos_token_ids=list(eos_token_ids), + client_label=client_label, + ) + try: + response = self._stub.CreateSession(request) + except grpc.RpcError as exc: + raise _wrap_grpc_error(exc) from exc + return Session(client=self, session_id=response.session_id) + + def close(self) -> None: + """Close the underlying gRPC channel. + + Idempotent: a second call is a no-op. The runtime's + sessions are NOT closed by this method — call + ``session.close()`` first if the runtime should free them. + """ + if self._closed: + return + self._channel.close() + self._closed = True + + def __enter__(self) -> "Client": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() diff --git a/sdks/python/kakeya/errors.py b/sdks/python/kakeya/errors.py new file mode 100644 index 00000000..bc6c7042 --- /dev/null +++ b/sdks/python/kakeya/errors.py @@ -0,0 +1,133 @@ +"""Typed Python exceptions for the Kakeya Python SDK (PR-B4). + +The SDK maps every gRPC status code raised by the Kakeya +RuntimeService into a typed :class:`KakeyaError` subclass. Callers +catch the typed exceptions; they should not need to import +``grpc`` to handle errors. + +Mapping (per ADR 0008 §2.6 / §2.10): + +============================== ===================================== +gRPC ``StatusCode`` SDK exception +============================== ===================================== +``NOT_FOUND`` :class:`SessionNotFoundError` +``INVALID_ARGUMENT`` :class:`InvalidArgumentError` +``FAILED_PRECONDITION`` :class:`InvariantViolationError` +``RESOURCE_EXHAUSTED`` :class:`ResourceExhaustedError` +``UNIMPLEMENTED`` :class:`UnimplementedError` +``CANCELLED`` :class:`RpcCancelledError` +(everything else) :class:`KakeyaError` (base class) +============================== ===================================== + +All SDK exceptions inherit from :class:`KakeyaError`, so users can +write a catch-all for "anything that came from the runtime" with +``except KakeyaError``. The ``rpc_code`` attribute carries the +underlying gRPC status code for callers that need it. + +A separate :class:`SessionClosedError` is raised by the SDK itself +(client-side) when a method is invoked on a closed +:class:`~kakeya.session.Session` — this never reaches the runtime. +""" + +from __future__ import annotations + +from typing import Optional + +import grpc + + +class KakeyaError(Exception): + """Base for every typed exception raised by the Kakeya SDK.""" + + def __init__( + self, + message: str, + *, + rpc_code: Optional[grpc.StatusCode] = None, + ) -> None: + super().__init__(message) + self.rpc_code = rpc_code + + +class SessionNotFoundError(KakeyaError): + """Raised when a ``session_id`` is not present on the runtime. + + The session may have been closed, evicted by LRU, evicted by + TTL, or removed by an invariant violation; the caller cannot + distinguish between these cases (per ADR 0008 §2.6 design). + """ + + +class InvalidArgumentError(KakeyaError): + """Raised when the runtime rejects a request as malformed. + + Common triggers (per the runtime contract): + * ``Generate`` called with sampling parameters set in v0.3 + greedy mode (``temperature != 0`` / ``top_p`` set / + ``top_k != 1``). + * ``Generate`` called before any ``AppendTokens`` for the + session. + * ``max_tokens`` < 1. + """ + + +class InvariantViolationError(KakeyaError): + """Raised when the runtime detects an INV-1 / INV-2 violation + on the session. Per ADR 0008 §2.8, the session has been + removed from the runtime; subsequent calls referencing the + same ``session_id`` return :class:`SessionNotFoundError`. + """ + + +class ResourceExhaustedError(KakeyaError): + """Raised when the runtime cannot admit a new session because + its slab pool is exhausted. The caller may retry after + closing or evicting other sessions. + """ + + +class UnimplementedError(KakeyaError): + """Raised when an RPC has not been implemented yet on the + runtime (e.g., a Servicer constructed without the + corresponding coordinator). This is distinct from Python's + builtin :class:`NotImplementedError` to avoid silent collision. + """ + + +class RpcCancelledError(KakeyaError): + """Raised on a ``CANCELLED`` gRPC status. Currently rare on the + SDK surface (cancellation is observable inside the streaming + iterator but does not raise on a fresh stream). + """ + + +class SessionClosedError(KakeyaError): + """Raised by the SDK itself — never crosses the wire — when a + method is called on a :class:`~kakeya.session.Session` whose + ``close()`` has already been invoked. + + This is a defensive client-side check, separate from + :class:`SessionNotFoundError` (which means the runtime lost + the session). + """ + + +def _wrap_grpc_error(exc: grpc.RpcError) -> KakeyaError: + """Translate a gRPC error into the Kakeya typed equivalent. + + Used by the SDK's RPC helpers; not part of the public surface. + """ + code = exc.code() + details = exc.details() if hasattr(exc, "details") else str(exc) + cls = _CODE_TO_EXCEPTION.get(code, KakeyaError) + return cls(details or "", rpc_code=code) + + +_CODE_TO_EXCEPTION: dict = { + grpc.StatusCode.NOT_FOUND: SessionNotFoundError, + grpc.StatusCode.INVALID_ARGUMENT: InvalidArgumentError, + grpc.StatusCode.FAILED_PRECONDITION: InvariantViolationError, + grpc.StatusCode.RESOURCE_EXHAUSTED: ResourceExhaustedError, + grpc.StatusCode.UNIMPLEMENTED: UnimplementedError, + grpc.StatusCode.CANCELLED: RpcCancelledError, +} diff --git a/sdks/python/kakeya/session.py b/sdks/python/kakeya/session.py new file mode 100644 index 00000000..7fd02dd0 --- /dev/null +++ b/sdks/python/kakeya/session.py @@ -0,0 +1,302 @@ +"""Kakeya Python SDK — :class:`Session` (PR-B4 of ADR 0008 Phase B). + +A handle to one server-side session. Mirrors the ADR 0008 §2.2 RPC +surface as Python methods: + + session.append(token_ids) -> AppendTokens + session.generate(max_tokens=N) -> Generate (server stream) + session.info() -> GetSessionInfo + session.close() -> CloseSession + +After ``close()``, every method except ``session_id`` / +``info()`` raises :class:`~kakeya.errors.SessionClosedError`. The +runtime-side state may have been freed earlier (LRU / TTL eviction); +the SDK doesn't track that — the next RPC will raise +:class:`~kakeya.errors.SessionNotFoundError` if the runtime no +longer knows the id. +""" + +from __future__ import annotations + +from typing import Iterable, Iterator, Optional, TYPE_CHECKING + +import grpc + +from inference_engine.server.proto_gen.kakeya.v1 import ( + runtime_pb2, + runtime_pb2_grpc, +) +from kakeya.errors import ( + SessionClosedError, + _wrap_grpc_error, +) + +if TYPE_CHECKING: + from kakeya.client import Client + + +class SessionInfo: + """Read-only snapshot of a session's server-side state. + + Returned by :meth:`Session.info`. Field names mirror + :class:`runtime_pb2.GetSessionInfoResponse` for direct + correspondence with the wire contract. + """ + + __slots__ = ( + "history_length", + "kv_live_bytes", + "cache_invariant_inv1_violations", + "cache_invariant_inv2_violations", + "idle_seconds", + ) + + def __init__( + self, + *, + history_length: int, + kv_live_bytes: int, + cache_invariant_inv1_violations: int, + cache_invariant_inv2_violations: int, + idle_seconds: float, + ) -> None: + self.history_length = history_length + self.kv_live_bytes = kv_live_bytes + self.cache_invariant_inv1_violations = cache_invariant_inv1_violations + self.cache_invariant_inv2_violations = cache_invariant_inv2_violations + self.idle_seconds = idle_seconds + + def __repr__(self) -> str: + return ( + f"SessionInfo(history_length={self.history_length}, " + f"kv_live_bytes={self.kv_live_bytes}, " + f"inv1={self.cache_invariant_inv1_violations}, " + f"inv2={self.cache_invariant_inv2_violations}, " + f"idle_seconds={self.idle_seconds:.3f})" + ) + + +class Session: + """One server-side session, addressable by ``session_id``.""" + + def __init__(self, *, client: "Client", session_id: str) -> None: + self._client = client + # Prefer the client's stub (already constructed) over making + # a new one; this keeps a single channel per Client instance. + self._stub: runtime_pb2_grpc.RuntimeServiceStub = client._stub + self._session_id = session_id + self._closed = False + # Populated after the most recent generate() call returns. + # Useful for callers that want the stop_reason / token count + # without having to wrap iteration in their own bookkeeping. + self._last_stop_reason: Optional[int] = None + self._last_generated_token_count: int = 0 + self._last_prefill_duration_seconds: float = 0.0 + self._last_total_duration_seconds: float = 0.0 + self._last_history_truncated_dropped: Optional[int] = None + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def session_id(self) -> str: + return self._session_id + + @property + def closed(self) -> bool: + return self._closed + + @property + def last_stop_reason(self) -> Optional[int]: + """``runtime_pb2.GenerateDone.StopReason`` enum value from the + most recent :meth:`generate` call, or ``None`` if no call has + been made yet.""" + return self._last_stop_reason + + @property + def last_generated_token_count(self) -> int: + return self._last_generated_token_count + + @property + def last_prefill_duration_seconds(self) -> float: + return self._last_prefill_duration_seconds + + @property + def last_total_duration_seconds(self) -> float: + return self._last_total_duration_seconds + + @property + def last_history_truncated_dropped(self) -> Optional[int]: + """If the most recent :meth:`generate` started in + sink+window-truncated mode, the runtime emitted a + ``HistoryTruncated`` event with the number of dropped + tokens — this property exposes that count. ``None`` if the + last call did not encounter truncation, or if no call has + been made yet.""" + return self._last_history_truncated_dropped + + # ------------------------------------------------------------------ + # RPC methods + # ------------------------------------------------------------------ + + def append(self, token_ids: Iterable[int]) -> int: + """Append raw token ids to the session's history. + + Returns the new ``history_length``. Empty input is a + runtime-side no-op. + """ + self._check_open() + request = runtime_pb2.AppendTokensRequest( + session_id=self._session_id, + token_ids=list(token_ids), + ) + try: + response = self._stub.AppendTokens(request) + except grpc.RpcError as exc: + raise _wrap_grpc_error(exc) from exc + return response.history_length + + def generate( + self, + *, + max_tokens: int, + seed: Optional[int] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + top_k: Optional[int] = None, + ) -> Iterator[int]: + """Stream generated token ids. + + Yields ``int`` token ids in generation order. The iterator + is exhausted when the server emits a ``GenerateDone`` + frame; metadata (stop reason, count, durations) is then + available via :attr:`last_stop_reason` etc. + + v0.3 supports only greedy decoding. Setting + ``temperature`` / ``top_p`` / ``top_k`` to anything other + than the greedy no-op default raises + :class:`~kakeya.errors.InvalidArgumentError` from the + runtime (per ADR 0008 §2.10 "no graceful degradation"). + """ + self._check_open() + # Reset metadata from any prior call before this one starts. + self._last_stop_reason = None + self._last_generated_token_count = 0 + self._last_prefill_duration_seconds = 0.0 + self._last_total_duration_seconds = 0.0 + self._last_history_truncated_dropped = None + + request = runtime_pb2.GenerateRequest( + session_id=self._session_id, + max_tokens=max_tokens, + ) + if seed is not None: + request.seed = seed + if temperature is not None: + request.temperature = temperature + if top_p is not None: + request.top_p = top_p + if top_k is not None: + request.top_k = top_k + + try: + for response in self._stub.Generate(request): + payload = response.WhichOneof("payload") + if payload == "token_id": + yield response.token_id + elif payload == "truncated": + self._last_history_truncated_dropped = ( + response.truncated.dropped_token_count + ) + elif payload == "done": + done = response.done + self._last_stop_reason = done.stop_reason + self._last_generated_token_count = ( + done.generated_token_count + ) + self._last_prefill_duration_seconds = ( + done.prefill_duration_seconds + ) + self._last_total_duration_seconds = ( + done.total_duration_seconds + ) + return + except grpc.RpcError as exc: + raise _wrap_grpc_error(exc) from exc + + def info(self) -> SessionInfo: + """Return a snapshot of the session's server-side state. + + Allowed even after :meth:`close` has been called locally — + in that case the call goes to the runtime and most likely + returns :class:`~kakeya.errors.SessionNotFoundError`. + """ + request = runtime_pb2.GetSessionInfoRequest( + session_id=self._session_id, + ) + try: + response = self._stub.GetSessionInfo(request) + except grpc.RpcError as exc: + raise _wrap_grpc_error(exc) from exc + return SessionInfo( + history_length=response.history_length, + kv_live_bytes=response.kv_live_bytes, + cache_invariant_inv1_violations=( + response.cache_invariant_inv1_violations + ), + cache_invariant_inv2_violations=( + response.cache_invariant_inv2_violations + ), + idle_seconds=response.idle_seconds, + ) + + def close(self) -> int: + """Close the session on the runtime. Returns the final + ``history_length``. + + Idempotent at the SDK level: a second call returns 0 + without contacting the runtime, the same way ``Client.close()`` + is idempotent. (A first close that fails on the wire still + flips the local closed flag — the SDK assumes the runtime + is unreachable rather than dribbling out further calls.) + """ + if self._closed: + return 0 + request = runtime_pb2.CloseSessionRequest( + session_id=self._session_id, + ) + try: + response = self._stub.CloseSession(request) + except grpc.RpcError as exc: + self._closed = True + raise _wrap_grpc_error(exc) from exc + self._closed = True + return response.final_history_length + + # ------------------------------------------------------------------ + # Context manager + # ------------------------------------------------------------------ + + def __enter__(self) -> "Session": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + # Best-effort close on context exit; swallow SessionNotFoundError + # because the runtime may have evicted the session between the + # last RPC and this exit. + try: + self.close() + except Exception: # pragma: no cover - best-effort cleanup + pass + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _check_open(self) -> None: + if self._closed: + raise SessionClosedError( + f"session {self._session_id!r} has been closed locally; " + "create a new session to continue work", + ) diff --git a/tests/sdk/python/__init__.py b/tests/sdk/python/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/sdk/python/conftest.py b/tests/sdk/python/conftest.py new file mode 100644 index 00000000..e32a3a60 --- /dev/null +++ b/tests/sdk/python/conftest.py @@ -0,0 +1,202 @@ +"""Test fixtures for the Kakeya Python SDK. + +The SDK is sync (``grpc.insecure_channel`` + sync stubs); the +runtime under test is async (``grpc.aio.server`` + async +servicer). They are wire-compatible (HTTP/2 gRPC), but the async +server needs an event loop running to respond to RPCs. + +The :func:`runtime_address` fixture spins up the async server in a +background thread with its own event loop and yields the +``host:port`` string the SDK can connect to. Cleanup stops the +server through a ``call_soon_threadsafe`` round-trip and joins the +thread. + +This pattern keeps SDK tests free of pytest-asyncio dependence +while still exercising the production gRPC machinery. No mocks of +the SUT — only a deterministic ``FakeVerifier`` (already shared +with the coordinator and gRPC-app test suites) so the runtime's +behavior is observable. +""" + +from __future__ import annotations + +import asyncio +import threading +from dataclasses import dataclass +from typing import Iterable, Iterator, Optional + +import grpc +import pytest + +from inference_engine.server.grpc_app import RuntimeServiceServicer +from inference_engine.server.proto_gen.kakeya.v1 import ( + runtime_pb2_grpc, +) +from inference_engine.session import ( + AppendTokensCoordinator, + GenerationCoordinator, + SessionStore, +) + +# Shared FakeVerifier from PR-B2's coordinator suite — same fake the +# rest of the Phase-B test suite uses. +from tests.inference_engine.session.test_coordinator import FakeVerifier + + +@dataclass +class RuntimeFixture: + """Convenience handle returned by :func:`runtime_address`.""" + + address: str + store: SessionStore + verifier: FakeVerifier + + +def _start_runtime( + *, + cache_inspector_enabled: bool = True, + slab_pool: Optional[object] = None, + capacity: int = 4, +) -> tuple[RuntimeFixture, threading.Thread, asyncio.AbstractEventLoop, "_ServerHolder"]: + """Spin up an async runtime in a background thread. + + Returns ``(fixture, thread, loop, server_holder)``. ``server_holder`` + boxes the server reference because the actual ``grpc.aio.Server`` + object is constructed *inside* the worker thread's loop — + constructing it in the main thread and using it in another + thread's loop produces ``Future attached to a different loop`` + errors from grpcio's internals. + + ``cache_inspector_enabled``: when True the FakeVerifier is also + wired into ``SessionStore`` as the ``cache_inspector`` so INV-1 + is enforced. Disable to test paths where INV-1 must not fire. + + ``slab_pool`` / ``capacity``: passed through to ``SessionStore`` + for tests that need a constrained pool (e.g., + RESOURCE_EXHAUSTED scenarios where ``capacity > num_slabs``). + """ + fv = FakeVerifier() + inspector = fv if cache_inspector_enabled else None + store_kwargs = {"capacity": capacity, "cache_inspector": inspector} + if slab_pool is not None: + store_kwargs["slab_pool"] = slab_pool + store = SessionStore(**store_kwargs) + append_coord = AppendTokensCoordinator(store, fv) + gen_coord = GenerationCoordinator(store, fv) + + loop = asyncio.new_event_loop() + server_holder = _ServerHolder() + port_holder: dict = {"port": None, "started": threading.Event()} + + async def _serve() -> None: + # Construct the server INSIDE the worker thread's loop so + # any internal asyncio.Future the server allocates is bound + # to this loop (and not the main-thread default loop). + server = grpc.aio.server() + runtime_pb2_grpc.add_RuntimeServiceServicer_to_server( + RuntimeServiceServicer( + store, + append_coordinator=append_coord, + generation_coordinator=gen_coord, + ), + server, + ) + server_holder.server = server + port_holder["port"] = server.add_insecure_port("127.0.0.1:0") + await server.start() + port_holder["started"].set() + # Block until server.stop() is scheduled from another thread. + # wait_for_termination() returns cleanly once stop() is invoked, + # which lets run_until_complete(_serve()) return without a + # cancellation tantrum. + await server.wait_for_termination() + + def _run() -> None: + asyncio.set_event_loop(loop) + loop.run_until_complete(_serve()) + + thread = threading.Thread(target=_run, daemon=True) + thread.start() + started = port_holder["started"].wait(timeout=5.0) + if not started: # pragma: no cover - environment fallback + raise RuntimeError("background gRPC server failed to start") + + fixture = RuntimeFixture( + address=f"127.0.0.1:{port_holder['port']}", + store=store, + verifier=fv, + ) + return fixture, thread, loop, server_holder + + +class _ServerHolder: + """Box for the gRPC server, populated inside the worker thread's + loop (see ``_start_runtime``).""" + + def __init__(self) -> None: + self.server: Optional[grpc.aio.Server] = None + + +def _stop_runtime( + thread: threading.Thread, + loop: asyncio.AbstractEventLoop, + server_holder: "_ServerHolder", +) -> None: + """Gracefully stop the background-thread runtime. + + Sequence: + + 1. Schedule ``server.stop(grace)`` on the worker loop. Once + it returns, ``server.wait_for_termination()`` (the body of + ``_serve()``) returns, which lets + ``loop.run_until_complete(_serve())`` return cleanly. + 2. Wait for the thread to finish naturally. + 3. Close the loop. No tasks are left scheduled, so this is + a clean close. + """ + server = server_holder.server + if server is None: # pragma: no cover - server creation completed before this is called + thread.join(timeout=2.0) + loop.close() + return + + async def _shutdown() -> None: + await server.stop(grace=0.1) + + fut = asyncio.run_coroutine_threadsafe(_shutdown(), loop) + try: + fut.result(timeout=2.0) + except Exception: # pragma: no cover - best-effort shutdown + pass + thread.join(timeout=2.0) + loop.close() + + +@pytest.fixture +def runtime_address() -> Iterator[RuntimeFixture]: + """Yield a live runtime; tear down on test teardown. + + The fixture is function-scoped: each test gets a fresh + SessionStore + FakeVerifier so cross-test state cannot leak. + Concurrent tests don't collide because each spins up on a free + port (``127.0.0.1:0``). + """ + fixture, thread, loop, server = _start_runtime() + try: + yield fixture + finally: + _stop_runtime(thread, loop, server) + + +@pytest.fixture +def runtime_address_no_inspector() -> Iterator[RuntimeFixture]: + """Variant: store has no ``cache_inspector``, so INV-1 cannot + fire from the SDK side. Useful for tests that exercise other + error paths without accidentally tripping INV-1.""" + fixture, thread, loop, server = _start_runtime( + cache_inspector_enabled=False, + ) + try: + yield fixture + finally: + _stop_runtime(thread, loop, server) diff --git a/tests/sdk/python/test_client.py b/tests/sdk/python/test_client.py new file mode 100644 index 00000000..5a847345 --- /dev/null +++ b/tests/sdk/python/test_client.py @@ -0,0 +1,137 @@ +"""Unit tests for :class:`kakeya.Client` (PR-B4). + +Tests run against a real :func:`runtime_address` fixture (background- +thread async server), so the SDK exercises actual gRPC machinery +end-to-end. +""" + +from __future__ import annotations + +import pytest + +from kakeya import ( + Client, + DEFAULT_ADDRESS, + ResourceExhaustedError, + Session, +) + + +class TestConstruction: + def test_default_address_constant(self): + assert DEFAULT_ADDRESS == "localhost:50051" + + def test_address_property(self): + client = Client("127.0.0.1:99999") + assert client.address == "127.0.0.1:99999" + client.close() + + def test_closed_property_default_false(self): + client = Client("127.0.0.1:99999") + assert client.closed is False + client.close() + + def test_channel_options_keyword_accepted(self): + # We pass a benign option to confirm the path; the option's + # effect is grpcio's domain. + client = Client( + "127.0.0.1:99999", + channel_options=[("grpc.enable_retries", 0)], + ) + client.close() + + +class TestClose: + def test_close_flips_closed_flag(self): + client = Client("127.0.0.1:99999") + client.close() + assert client.closed is True + + def test_close_is_idempotent(self): + client = Client("127.0.0.1:99999") + client.close() + client.close() # must not raise + assert client.closed is True + + +class TestContextManager: + def test_with_block_closes_on_exit(self): + with Client("127.0.0.1:99999") as client: + assert client.closed is False + assert client.closed is True + + def test_context_manager_closes_on_exception(self): + client = None + with pytest.raises(RuntimeError, match="boom"): + with Client("127.0.0.1:99999") as c: + client = c + raise RuntimeError("boom") + assert client is not None and client.closed is True + + +class TestCreateSession: + def test_returns_session_with_server_issued_id(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + assert isinstance(session, Session) + assert session.session_id.startswith("sess-") + session.close() + + def test_eos_token_ids_passed_through(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session(eos_token_ids=[7, 11, 13]) + store_session = runtime_address.store.get_session( + session.session_id, + ) + assert store_session.eos_token_ids == (7, 11, 13) + session.close() + + def test_client_label_passed_through(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session(client_label="demo-1") + store_session = runtime_address.store.get_session( + session.session_id, + ) + assert store_session.client_label == "demo-1" + session.close() + + def test_default_args_produce_empty_eos(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + store_session = runtime_address.store.get_session( + session.session_id, + ) + assert store_session.eos_token_ids == () + assert store_session.client_label == "" + session.close() + + def test_resource_exhausted_raises_typed_exception(self): + # Use a runtime with capacity > num_slabs so the second + # create_session can't be satisfied by LRU eviction (still + # within capacity) and the slab pool exhausts. The fixture- + # constructed runtime accepts a slab_pool kwarg so we can + # build this scenario without re-implementing the thread/ + # loop dance inline. + from inference_engine.memory.pool import SlabPool + from inference_engine.memory.slab import SlabConfig + from tests.sdk.python.conftest import _start_runtime, _stop_runtime + + cfg = SlabConfig( + num_layers=1, num_heads=1, sink_size=1, + window_size=2, head_dim=4, + ) + pool = SlabPool(num_slabs=1, slab_config=cfg) + fixture, thread, loop, holder = _start_runtime( + cache_inspector_enabled=False, + slab_pool=pool, + capacity=4, + ) + try: + with Client(fixture.address) as client: + client.create_session() # consumes the only slab + with pytest.raises(ResourceExhaustedError) as exc: + client.create_session() # pool empty + assert exc.value.rpc_code is not None + assert "slab pool exhausted" in str(exc.value) + finally: + _stop_runtime(thread, loop, holder) diff --git a/tests/sdk/python/test_errors.py b/tests/sdk/python/test_errors.py new file mode 100644 index 00000000..6ce4a8c5 --- /dev/null +++ b/tests/sdk/python/test_errors.py @@ -0,0 +1,131 @@ +"""Unit tests for :mod:`kakeya.errors` (PR-B4). + +The error wrapper :func:`_wrap_grpc_error` is internal but +load-bearing — every gRPC failure on the SDK surface goes through +it. Tests verify the gRPC status -> typed Python class mapping for +every documented status code, plus the catch-all path for unknown +codes. +""" + +from __future__ import annotations + +import grpc +import pytest + +from kakeya.errors import ( + InvalidArgumentError, + InvariantViolationError, + KakeyaError, + ResourceExhaustedError, + RpcCancelledError, + SessionClosedError, + SessionNotFoundError, + UnimplementedError, + _wrap_grpc_error, +) + + +class _SyntheticRpcError(grpc.RpcError): + """Minimal grpc.RpcError stand-in for unit-testing the error + wrapper. The real ``_InactiveRpcError`` constructor is private + in grpcio, so we synthesize the same .code() / .details() + surface.""" + + def __init__(self, code: grpc.StatusCode, details: str) -> None: + super().__init__(details) + self._code = code + self._details = details + + def code(self) -> grpc.StatusCode: + return self._code + + def details(self) -> str: + return self._details + + +class TestKakeyaErrorBase: + def test_carries_message(self): + err = KakeyaError("boom") + assert str(err) == "boom" + + def test_carries_rpc_code_when_provided(self): + err = KakeyaError("boom", rpc_code=grpc.StatusCode.NOT_FOUND) + assert err.rpc_code is grpc.StatusCode.NOT_FOUND + + def test_rpc_code_defaults_to_none(self): + err = KakeyaError("boom") + assert err.rpc_code is None + + def test_session_closed_error_has_no_rpc_code(self): + err = SessionClosedError("session closed locally") + assert err.rpc_code is None + assert "session closed" in str(err) + + +class TestSubclassHierarchy: + @pytest.mark.parametrize("cls", [ + SessionNotFoundError, + InvalidArgumentError, + InvariantViolationError, + ResourceExhaustedError, + UnimplementedError, + RpcCancelledError, + SessionClosedError, + ]) + def test_subclasses_kakeya_error(self, cls): + assert issubclass(cls, KakeyaError) + + +class TestWrapGrpcError: + @pytest.mark.parametrize("code, expected_cls", [ + (grpc.StatusCode.NOT_FOUND, SessionNotFoundError), + (grpc.StatusCode.INVALID_ARGUMENT, InvalidArgumentError), + (grpc.StatusCode.FAILED_PRECONDITION, InvariantViolationError), + (grpc.StatusCode.RESOURCE_EXHAUSTED, ResourceExhaustedError), + (grpc.StatusCode.UNIMPLEMENTED, UnimplementedError), + (grpc.StatusCode.CANCELLED, RpcCancelledError), + ]) + def test_known_status_maps_to_typed_subclass(self, code, expected_cls): + synthetic = _SyntheticRpcError(code, "details from server") + wrapped = _wrap_grpc_error(synthetic) + assert isinstance(wrapped, expected_cls) + assert wrapped.rpc_code is code + assert "details from server" in str(wrapped) + + def test_unknown_status_falls_back_to_kakeya_error(self): + # Use a code that's not in the documented mapping. + synthetic = _SyntheticRpcError( + grpc.StatusCode.INTERNAL, "server exploded", + ) + wrapped = _wrap_grpc_error(synthetic) + # Falls back to base KakeyaError (not any specific subclass). + assert type(wrapped) is KakeyaError + assert wrapped.rpc_code is grpc.StatusCode.INTERNAL + assert "server exploded" in str(wrapped) + + def test_empty_details_does_not_crash(self): + synthetic = _SyntheticRpcError(grpc.StatusCode.NOT_FOUND, "") + wrapped = _wrap_grpc_error(synthetic) + assert isinstance(wrapped, SessionNotFoundError) + assert wrapped.rpc_code is grpc.StatusCode.NOT_FOUND + + def test_rpc_error_without_details_method(self): + # Real grpc.RpcError instances should always have .details(), + # but the wrapper handles the bare-RpcError case defensively + # by falling back to str(exc). + bare = grpc.RpcError("bare") + # bare doesn't have code(); we don't go through the wrapper + # in production for objects like this. But the fallback path + # in _wrap_grpc_error reads .details() guarded by hasattr — + # let's confirm bare objects don't reach the wrapper by + # construction. (Coverage of the hasattr branch is exercised + # below with a code-only synthetic.) + + class _CodeOnlyError(grpc.RpcError): + def code(self): + return grpc.StatusCode.NOT_FOUND + # no details() method + + wrapped = _wrap_grpc_error(_CodeOnlyError("fallback")) + assert isinstance(wrapped, SessionNotFoundError) + assert "fallback" in str(wrapped) diff --git a/tests/sdk/python/test_session.py b/tests/sdk/python/test_session.py new file mode 100644 index 00000000..b4a37bb7 --- /dev/null +++ b/tests/sdk/python/test_session.py @@ -0,0 +1,388 @@ +"""Unit tests for :class:`kakeya.Session` (PR-B4). + +Tests run against a real :func:`runtime_address` fixture (background- +thread async server), so the SDK exercises actual gRPC streaming +end-to-end. +""" + +from __future__ import annotations + +import pytest + +from kakeya import ( + Client, + InvalidArgumentError, + InvariantViolationError, + Session, + SessionClosedError, + SessionInfo, + SessionNotFoundError, +) +from inference_engine.server.proto_gen.kakeya.v1 import runtime_pb2 + + +# --------------------------------------------------------------------------- +# Properties + closed contract +# --------------------------------------------------------------------------- + + +class TestPropertiesAndClosed: + def test_session_id_is_server_issued(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + assert session.session_id.startswith("sess-") + session.close() + + def test_closed_default_false(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + assert session.closed is False + session.close() + assert session.closed is True + + def test_last_metadata_defaults(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + assert session.last_stop_reason is None + assert session.last_generated_token_count == 0 + assert session.last_prefill_duration_seconds == 0.0 + assert session.last_total_duration_seconds == 0.0 + assert session.last_history_truncated_dropped is None + session.close() + + +# --------------------------------------------------------------------------- +# append() +# --------------------------------------------------------------------------- + + +class TestAppend: + def test_returns_history_length(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + new_len = session.append([10, 20, 30]) + assert new_len == 3 + session.close() + + def test_appends_extend_history(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([10, 20]) + new_len = session.append([30]) + assert new_len == 3 + session.close() + + def test_empty_input_is_noop(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1]) + new_len = session.append([]) + assert new_len == 1 + session.close() + + def test_after_local_close_raises_session_closed_error( + self, runtime_address, + ): + with Client(runtime_address.address) as client: + session = client.create_session() + session.close() + with pytest.raises(SessionClosedError): + session.append([1, 2, 3]) + + def test_unknown_session_after_runtime_close_raises_not_found( + self, runtime_address, + ): + # Bypass local close-tracking by stashing the session_id and + # creating a fresh local Session object pointed at an id that + # the runtime doesn't know. + with Client(runtime_address.address) as client: + phantom = Session(client=client, session_id="sess-phantom") + with pytest.raises(SessionNotFoundError): + phantom.append([1]) + + +# --------------------------------------------------------------------------- +# generate() +# --------------------------------------------------------------------------- + + +class TestGenerate: + def test_yields_token_ids_in_order(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1, 2, 3]) + tokens = list(session.generate(max_tokens=3)) + assert len(tokens) == 3 + assert all(isinstance(t, int) for t in tokens) + session.close() + + def test_sets_last_metadata_after_iteration(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1, 2, 3]) + list(session.generate(max_tokens=2)) + assert session.last_stop_reason == \ + runtime_pb2.GenerateDone.STOP_REASON_MAX_TOKENS + assert session.last_generated_token_count == 2 + assert session.last_total_duration_seconds >= 0.0 + assert session.last_prefill_duration_seconds == 0.0 + session.close() + + def test_eos_terminates_with_eos_stop_reason(self, runtime_address): + # FakeVerifier's deterministic argmax = sum(history[-3:]) % 16. + # Initial history [1, 2, 3] -> first generated token = 6. + with Client(runtime_address.address) as client: + session = client.create_session(eos_token_ids=[6]) + session.append([1, 2, 3]) + tokens = list(session.generate(max_tokens=10)) + assert tokens == [6] + assert session.last_stop_reason == \ + runtime_pb2.GenerateDone.STOP_REASON_EOS + session.close() + + def test_records_history_truncated_metadata(self, runtime_address): + # FakeVerifier's default sink+window = 6. Append 8 tokens to + # make the cache truncated; then generate. + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([10, 20, 30, 40, 50, 60, 70, 80]) + tokens = list(session.generate(max_tokens=2)) + assert len(tokens) == 2 + # 8 history - 6 cache = 2 dropped at start of generate. + assert session.last_history_truncated_dropped == 2 + session.close() + + def test_no_truncation_leaves_metadata_none(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1, 2, 3]) + list(session.generate(max_tokens=1)) + assert session.last_history_truncated_dropped is None + session.close() + + def test_metadata_resets_between_calls(self, runtime_address): + # generate() resets every last_* property at start. We + # verify by running a CALL that emits NO truncated frame + # AFTER one that did: the second call's + # last_history_truncated_dropped must be None, not the + # first call's value. + # + # We can't easily switch a session out of truncated mode + # once it's in (sink+window cap is permanent for that + # session), so we test the inverse path: do the + # non-truncated call first, then the truncated call. After + # the second call last_history_truncated_dropped is + # populated; after the FIRST it must be None. + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1, 2, 3]) # under sink+window + list(session.generate(max_tokens=1)) + assert session.last_history_truncated_dropped is None + + # Now push the cache past sink+window and call again. + session.append([10, 20, 30, 40, 50, 60, 70, 80]) + list(session.generate(max_tokens=1)) + assert isinstance(session.last_history_truncated_dropped, int) + assert session.last_history_truncated_dropped > 0 + session.close() + + def test_after_local_close_raises_session_closed_error( + self, runtime_address, + ): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1]) + session.close() + with pytest.raises(SessionClosedError): + list(session.generate(max_tokens=1)) + + def test_no_history_raises_invalid_argument(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + with pytest.raises(InvalidArgumentError): + list(session.generate(max_tokens=1)) + session.close() + + def test_temperature_nonzero_raises_invalid_argument( + self, runtime_address, + ): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1, 2, 3]) + with pytest.raises(InvalidArgumentError): + list(session.generate(max_tokens=1, temperature=0.7)) + session.close() + + def test_top_p_set_raises_invalid_argument(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1, 2, 3]) + with pytest.raises(InvalidArgumentError): + list(session.generate(max_tokens=1, top_p=0.9)) + session.close() + + def test_top_k_other_than_one_raises_invalid_argument( + self, runtime_address, + ): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1, 2, 3]) + with pytest.raises(InvalidArgumentError): + list(session.generate(max_tokens=1, top_k=50)) + session.close() + + def test_seed_accepted(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1, 2, 3]) + tokens = list(session.generate(max_tokens=2, seed=42)) + assert len(tokens) == 2 + session.close() + + def test_temperature_zero_accepted(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1, 2, 3]) + tokens = list(session.generate(max_tokens=1, temperature=0.0)) + assert len(tokens) == 1 + session.close() + + def test_top_k_one_accepted(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1, 2, 3]) + tokens = list(session.generate(max_tokens=1, top_k=1)) + assert len(tokens) == 1 + session.close() + + +# --------------------------------------------------------------------------- +# info() +# --------------------------------------------------------------------------- + + +class TestInfo: + def test_returns_session_info_dataclass(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([1, 2, 3]) + info = session.info() + assert isinstance(info, SessionInfo) + assert info.history_length == 3 + assert info.cache_invariant_inv1_violations == 0 + assert info.cache_invariant_inv2_violations == 0 + assert info.idle_seconds >= 0.0 + session.close() + + def test_repr_includes_all_fields(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + info = session.info() + text = repr(info) + for needle in ( + "history_length=", "kv_live_bytes=", "inv1=", + "inv2=", "idle_seconds=", + ): + assert needle in text, f"missing {needle} in {text!r}" + session.close() + + def test_unknown_session_raises_not_found(self, runtime_address): + with Client(runtime_address.address) as client: + phantom = Session(client=client, session_id="sess-x") + with pytest.raises(SessionNotFoundError): + phantom.info() + + +# --------------------------------------------------------------------------- +# close() +# --------------------------------------------------------------------------- + + +class TestClose: + def test_returns_final_history_length(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.append([10, 20, 30]) + assert session.close() == 3 + + def test_zero_for_empty_session(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + assert session.close() == 0 + + def test_idempotent_after_first_close(self, runtime_address): + with Client(runtime_address.address) as client: + session = client.create_session() + session.close() + assert session.close() == 0 # no RPC, no error + + def test_rpc_error_on_close_still_flips_closed_flag(self, runtime_address): + # Phantom session: close() RPC returns NOT_FOUND; we still + # set self._closed = True so subsequent calls don't make + # phantom RPCs. + with Client(runtime_address.address) as client: + phantom = Session(client=client, session_id="sess-not-here") + with pytest.raises(SessionNotFoundError): + phantom.close() + assert phantom.closed is True + # Subsequent close() is a no-op. + assert phantom.close() == 0 + + +# --------------------------------------------------------------------------- +# Context manager +# --------------------------------------------------------------------------- + + +class TestContextManager: + def test_with_block_closes_on_exit(self, runtime_address): + with Client(runtime_address.address) as client: + with client.create_session() as session: + assert session.closed is False + assert session.closed is True + + def test_context_manager_swallows_close_exception_on_exit( + self, runtime_address, + ): + with Client(runtime_address.address) as client: + session = client.create_session() + session.close() # close once normally + # Now enter as context manager and let __exit__ try to + # close again — close() is idempotent so this is also fine. + with session: + pass + assert session.closed is True + + +# --------------------------------------------------------------------------- +# SessionInfo dataclass surface +# --------------------------------------------------------------------------- + + +class TestSessionInfoStandalone: + def test_constructor_and_attributes(self): + info = SessionInfo( + history_length=5, + kv_live_bytes=12345, + cache_invariant_inv1_violations=0, + cache_invariant_inv2_violations=0, + idle_seconds=1.234, + ) + assert info.history_length == 5 + assert info.kv_live_bytes == 12345 + assert info.cache_invariant_inv1_violations == 0 + assert info.cache_invariant_inv2_violations == 0 + assert info.idle_seconds == 1.234 + + def test_repr_format(self): + info = SessionInfo( + history_length=1, kv_live_bytes=2, + cache_invariant_inv1_violations=3, + cache_invariant_inv2_violations=4, + idle_seconds=5.6, + ) + assert "history_length=1" in repr(info) + assert "kv_live_bytes=2" in repr(info) + assert "inv1=3" in repr(info) + assert "inv2=4" in repr(info) + assert "idle_seconds=5.600" in repr(info)