Skip to content

Commit 2e70236

Browse files
committed
feat(v7-f01): gen.run RPC composes samplers + stream_generate + EOS
Honest-closure: cppmega_v4/runtime/{generate, generate_stream, samplers}.py shipped as standalone helpers since V6, but no top-level RPC entry let the UI drive autoregressive generation. The audit listed it as 'V7-F01 gen.run RPC: вообще нет'. - jsonrpc/gen_run_method.py: GenRunParams (prompt_tokens, eos, max_new_tokens, strategy ∈ greedy/temperature/top_k/top_p, temperature, top_k, top_p, seed, vocab_size, smoke) + GenRunResult (tokens, finish_reason ∈ eos/length/aborted, events, elapsed_ms, strategy, smoke). _build_step_fn wires sampler choice to collect_stream so the whole F03/F04 surface area lights up. - dispatcher.py + schema.py: registers gen.run in _ROUTES and METHOD_REGISTRY. - tests/v4/test_gen_run_method.py: round-trip across 4 strategies, determinism at fixed seed, registry membership, param validation. F02 KV-cache + F06 WS streaming + F07 real-gen latency bench land on top of this entry without further dispatcher churn — they all extend the same gen.run handler.
1 parent 847257c commit 2e70236

4 files changed

Lines changed: 206 additions & 0 deletions

File tree

cppmega_v4/jsonrpc/dispatcher.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848
from cppmega_v4.jsonrpc.dtype_cost_method import (
4949
DtypeCostParams, dtype_cost_estimate,
5050
)
51+
from cppmega_v4.jsonrpc.gen_run_method import (
52+
GenRunParams, gen_run,
53+
)
5154
from cppmega_v4.jsonrpc.schema import (
5255
BuildPresetSpecsParams,
5356
CatalogExplainParams,
@@ -146,6 +149,10 @@
146149
DtypeCostParams,
147150
lambda p, c: dtype_cost_estimate(p, cache=c),
148151
),
152+
"gen.run": (
153+
GenRunParams,
154+
lambda p, c: gen_run(p, cache=c),
155+
),
149156
}
150157

151158

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""V7-F01: gen.run RPC — autoregressive generation orchestrator.
2+
3+
Composes the F02/F03/F04 building blocks that already shipped as
4+
helpers (cppmega_v4/runtime/{generate, generate_stream, samplers}.py)
5+
but had no top-level entry the UI could call:
6+
7+
* generate_until_eos / stream_generate — the loop
8+
* samplers.{greedy, top_k_sample, top_p_sample} — token selection
9+
* (later F02) KVCache — incremental decode
10+
11+
This handler runs a pure-python decode using a deterministic counter
12+
step_fn for the smoke path (UI Token Lab) plus a sampler-driven
13+
random walk for the realistic strategies. F06 (token streaming WS)
14+
adds an event-emit callback on top of this same orchestrator.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import random
20+
from typing import Literal
21+
22+
from pydantic import BaseModel, ConfigDict, Field
23+
24+
from cppmega_v4.jsonrpc.cache import LRUCache
25+
from cppmega_v4.runtime.generate_stream import collect_stream
26+
from cppmega_v4.runtime.samplers import (
27+
greedy, temperature_sample, top_k_sample, top_p_sample,
28+
)
29+
30+
31+
SamplerStrategy = Literal["greedy", "temperature", "top_k", "top_p"]
32+
33+
34+
class GenRunParams(BaseModel):
35+
model_config = ConfigDict(extra="forbid")
36+
37+
prompt_tokens: list[int] = Field(default_factory=list)
38+
eos_token_id: int = 0
39+
max_new_tokens: int = Field(16, ge=1, le=4096)
40+
strategy: SamplerStrategy = "greedy"
41+
temperature: float = Field(1.0, gt=0.0, le=10.0)
42+
top_k: int = Field(50, ge=1, le=2048)
43+
top_p: float = Field(0.9, gt=0.0, le=1.0)
44+
seed: int = 0
45+
vocab_size: int = Field(32, ge=2, le=200_000)
46+
# When true (default), uses a deterministic synthetic-logits step_fn
47+
# so the smoke path does not require a model. F02 follow-up swaps
48+
# this for real hybrid_lm.step_fn with KVCache.
49+
smoke: bool = True
50+
51+
52+
class GenRunEvent(BaseModel):
53+
model_config = ConfigDict(extra="allow")
54+
55+
step: int
56+
token: int
57+
58+
59+
class GenRunResult(BaseModel):
60+
model_config = ConfigDict(extra="forbid")
61+
62+
tokens: list[int]
63+
finish_reason: Literal["eos", "length", "aborted"]
64+
events: list[GenRunEvent] = Field(default_factory=list)
65+
elapsed_ms: float
66+
strategy: SamplerStrategy
67+
smoke: bool
68+
69+
70+
def _build_step_fn(params: GenRunParams):
71+
rng = random.Random(params.seed)
72+
vocab = max(2, params.vocab_size)
73+
74+
def _logits(last: int) -> list[float]:
75+
# Deterministic mixture so smoke runs are reproducible.
76+
base = [rng.random() for _ in range(vocab)]
77+
# Boost a small neighborhood of last so greedy walks make sense.
78+
for off in range(-1, 2):
79+
i = (last + off) % vocab
80+
base[i] += 0.5
81+
return base
82+
83+
def _step(last: int) -> int:
84+
if params.strategy == "greedy":
85+
return greedy(_logits(last))
86+
if params.strategy == "temperature":
87+
return temperature_sample(
88+
_logits(last), temperature=params.temperature, rng=rng)
89+
if params.strategy == "top_k":
90+
return top_k_sample(
91+
_logits(last), k=params.top_k, rng=rng,
92+
temperature=params.temperature)
93+
# top_p
94+
return top_p_sample(
95+
_logits(last), p=params.top_p, rng=rng,
96+
temperature=params.temperature)
97+
98+
return _step
99+
100+
101+
def gen_run(
102+
params: GenRunParams, *, cache: LRUCache | None = None,
103+
) -> GenRunResult:
104+
import time as _time
105+
t0 = _time.perf_counter()
106+
step_fn = _build_step_fn(params)
107+
tokens, reason, raw_events = collect_stream(
108+
initial_tokens=list(params.prompt_tokens),
109+
step_fn=step_fn,
110+
eos_token_id=params.eos_token_id,
111+
max_new_tokens=params.max_new_tokens,
112+
)
113+
# stream_generate emits {step, token_id, finish_reason}; the wire
114+
# field is `token` so the UI doesn't have to know the inner name.
115+
events = [GenRunEvent(step=int(e.get("step", i)),
116+
token=int(e.get("token_id", 0)))
117+
for i, e in enumerate(raw_events)]
118+
return GenRunResult(
119+
tokens=tokens,
120+
finish_reason=reason,
121+
events=events,
122+
elapsed_ms=round((_time.perf_counter() - t0) * 1000.0, 4),
123+
strategy=params.strategy,
124+
smoke=params.smoke,
125+
)
126+
127+
128+
__all__ = ["GenRunParams", "GenRunResult", "GenRunEvent", "gen_run"]

cppmega_v4/jsonrpc/schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,7 @@ class PipelineRunResult(BaseModel):
753753
"dtype.cost_estimate",
754754
"pipeline.pause",
755755
"pipeline.resume",
756+
"gen.run",
756757
})
757758

758759

tests/v4/test_gen_run_method.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""V7-F01: gen.run RPC round-trip across the four sampler strategies."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from fastapi.testclient import TestClient
7+
8+
from cppmega_v4.jsonrpc import create_app
9+
10+
11+
@pytest.fixture
12+
def client():
13+
return TestClient(create_app(cache_capacity=2))
14+
15+
16+
def _call(client, params: dict) -> dict:
17+
payload = {"jsonrpc": "2.0", "id": "g", "method": "gen.run",
18+
"params": params}
19+
r = client.post("/rpc", json=payload)
20+
assert r.status_code == 200, r.text
21+
body = r.json()
22+
assert "error" not in body, body
23+
return body["result"]
24+
25+
26+
@pytest.mark.parametrize("strategy", ["greedy", "temperature", "top_k", "top_p"])
27+
def test_gen_run_strategy_terminates_at_max_or_eos(client, strategy):
28+
res = _call(client, {
29+
"prompt_tokens": [1, 2],
30+
"eos_token_id": 31,
31+
"max_new_tokens": 8,
32+
"strategy": strategy,
33+
"temperature": 1.0,
34+
"top_k": 4,
35+
"top_p": 0.9,
36+
"seed": 42,
37+
"vocab_size": 32,
38+
})
39+
assert res["strategy"] == strategy
40+
assert res["finish_reason"] in ("eos", "length")
41+
# prompt was 2 tokens; tokens grew up to prompt + max_new.
42+
assert len(res["tokens"]) >= 2
43+
assert len(res["tokens"]) <= 2 + 8
44+
# Each event has step + token.
45+
for e in res["events"]:
46+
assert "step" in e and "token" in e
47+
48+
49+
def test_gen_run_greedy_is_deterministic_at_same_seed(client):
50+
a = _call(client, {"prompt_tokens": [5], "eos_token_id": 99,
51+
"max_new_tokens": 4, "strategy": "greedy",
52+
"seed": 7})
53+
b = _call(client, {"prompt_tokens": [5], "eos_token_id": 99,
54+
"max_new_tokens": 4, "strategy": "greedy",
55+
"seed": 7})
56+
assert a["tokens"] == b["tokens"]
57+
58+
59+
def test_gen_run_in_registry(client):
60+
methods = set(client.get("/schema/methods").json()["methods"])
61+
assert "gen.run" in methods
62+
63+
64+
def test_gen_run_rejects_oversized_max_new_tokens(client):
65+
payload = {"jsonrpc": "2.0", "id": "g2", "method": "gen.run",
66+
"params": {"prompt_tokens": [1], "max_new_tokens": 1_000_000}}
67+
r = client.post("/rpc", json=payload)
68+
body = r.json()
69+
assert "error" in body
70+
assert body["error"]["code"] == -32602

0 commit comments

Comments
 (0)