Dev-4B / dmm /engine.py
suhaas-teja's picture
Engine: streaming auto and chat
e4c9005 verified
Raw History Blame Contribute Delete
10.8 kB
"""The inference engine behind the server: decisions, generation, and decide-then-generate.
One model in memory. Decisions prefill the state once with the base (adapter off), then run each
question suffix with the adapter on against that cache. Generation runs with the adapter off.
`auto` decides first and, when the router flags the question, decodes from the same state cache,
so escalation never prefills the state a second time.
Requests use TypeSafe-style question types: noul (yes/no), choice (named options, at most 16)
and score (ordered levels, at most 6).
"""
import json
import threading
from pathlib import Path
import numpy as np
import torch
from dmm import lora
from dmm.artifacts import read_router, router_file, weights_file
from dmm.cache import StateCache
from dmm.head import DecisionHead, option_logits
from dmm.router import Router, confidence_features
from dmm.template import Template, chat_text, decision_text
def to_item(state: str, name: str, q: dict) -> dict:
"""A TypeSafe-style question as one of our decision items."""
kind = q["type"]
text = str(q.get("instructions") or "").strip()
if kind == "noul":
return {
"id": name,
"kind": "yesno",
"state": state,
"question": text,
"options": ["no", "yes"],
"answer": None,
}
if kind == "choice":
names = list(q["criteria"])
if not 1 <= len(names) <= 16:
raise ValueError(f"{name}: choice takes 1 to 16 options, got {len(names)}")
opts = [n if not q["criteria"][n] else f"{n}: {q['criteria'][n]}" for n in names]
return {
"id": name,
"kind": "choice",
"state": state,
"question": text,
"options": opts,
"answer": None,
"_names": names,
}
if kind == "score":
levels = list(q["criteria"])
if not 2 <= len(levels) <= 6:
raise ValueError(f"{name}: score takes 2 to 6 levels, got {len(levels)}")
legend = "; ".join(f"{i} = {lv}" for i, lv in enumerate(levels))
return {
"id": name,
"kind": "score",
"state": state,
"question": f"{text} ({legend})",
"options": [str(i) for i in range(len(levels))],
"answer": None,
}
raise ValueError(f"{name}: unknown question type {kind!r}")
class Engine:
def __init__(
self,
model,
tokenizer,
head: DecisionHead,
temperatures: dict,
router: Router | None = None,
router_threshold: float = 1.0,
):
self.model, self.tok, self.head = model, tokenizer, head
self.tpl = Template(tokenizer)
self.temperatures, self.router, self.threshold = temperatures, router, router_threshold
self.pad = tokenizer.pad_token_id or tokenizer.eos_token_id
self.lock = threading.Lock() # one request on the model at a time
self.model.eval()
@classmethod
def from_artifacts(cls, model, tokenizer, cfg: dict, ckpt_dir: str, router_dir: str | None):
from train.train_decision import load_checkpoint, prepare
head, _ = prepare(model, tokenizer, cfg)
load_checkpoint(model, head, weights_file(ckpt_dir))
temps = json.loads(Path(f"{ckpt_dir}/temperatures.json").read_text())
router, threshold = None, 1.0
if router_dir:
router = Router(model.config.hidden_size)
router.load_state_dict(read_router(router_file(router_dir)))
threshold = json.loads(Path(f"{router_dir}/operating_point.json").read_text())[
"router_threshold"
]
return cls(model, tokenizer, head.float(), temps, router, threshold)
# Decisions ---------------------------------------------------------------------------
@torch.no_grad()
def _decide_on(self, cache: StateCache, item: dict) -> dict:
h = cache.run_suffix(self.tpl.encode(decision_text(item)))
z = option_logits(self.head(h[None].to(self.head.proj.weight.device)), [item])[0]
z = z.float() / self.temperatures.get(item["kind"], 1.0)
p = torch.softmax(z, -1).cpu().numpy().astype(np.float64)
score = None
if self.router is not None:
hf = h.float().cpu().numpy()[None]
score = float(self.router.score(hf, confidence_features([p]))[0])
return {
"probs": p,
"router_score": score,
"escalate": score is not None and score >= self.threshold,
}
def decide(self, state: str, questions: dict[str, dict]) -> dict:
items = {name: to_item(state, name, q) for name, q in questions.items()}
with self.lock:
cache = StateCache(self.model, self.tpl.state_ids(next(iter(items.values()))))
raw = {name: self._decide_on(cache, it) for name, it in items.items()}
return {name: self._answer(items[name], r) for name, r in raw.items()}
@staticmethod
def _answer(item: dict, r: dict) -> dict:
p = r["probs"]
out = {
"type": {"yesno": "noul", "choice": "choice", "score": "score"}[item["kind"]],
"router_score": r["router_score"],
"needs_generation": r["escalate"],
}
if item["kind"] == "yesno":
out |= {
"noul": float(p[1]),
"probabilities": {"false": float(p[0]), "true": float(p[1])},
}
elif item["kind"] == "choice":
names = item["_names"]
out |= {
"choice": names[int(np.argmax(p))],
"probabilities": {n: float(x) for n, x in zip(names, p, strict=True)},
}
else:
out |= {
"score": float(np.dot(p, np.arange(len(p)))),
"probabilities": {str(i): float(x) for i, x in enumerate(p)},
}
return out
# Generation --------------------------------------------------------------------------
@torch.no_grad()
def _generate(
self, ids: list[int], max_new_tokens: int, cache: StateCache | None = None, streamer=None
):
x = torch.tensor([ids], device=self.model.device)
kw = {"past_key_values": cache.kv} if cache is not None else {}
if streamer is not None:
kw["streamer"] = streamer
with lora.disabled(self.model):
out = self.model.generate(
x,
attention_mask=torch.ones_like(x),
do_sample=False,
max_new_tokens=max_new_tokens,
pad_token_id=self.pad,
**kw,
)
return out[0, len(ids) :].tolist()
def _stream(self, ids: list[int], max_new_tokens: int, cache: StateCache | None = None):
"""Generate as _generate does, yielding {"event": "text"} pieces as they decode.
Returns the generated token ids (the generator's return value). Generation runs in a
thread feeding a streamer; the thread is always joined, even if the caller stops early,
so the model and the state cache are never touched by two requests at once.
"""
from transformers import TextIteratorStreamer
streamer = TextIteratorStreamer(self.tok, skip_prompt=True, skip_special_tokens=True)
result: dict = {}
def run():
try:
result["tokens"] = self._generate(ids, max_new_tokens, cache, streamer)
except BaseException as e: # surface in the caller's thread
result["error"] = e
streamer.end()
t = threading.Thread(target=run, daemon=True)
t.start()
try:
for piece in streamer:
if piece:
yield {"event": "text", "text": piece}
finally:
t.join()
if "error" in result:
raise result["error"]
return result["tokens"]
def chat_stream(self, messages: list[dict], max_new_tokens: int = 512):
"""chat(), streamed: {"event": "text"} pieces, then {"event": "done", "tokens": [...]}."""
ids = self.tok.apply_chat_template(
messages, add_generation_prompt=True, tokenize=True, return_dict=True
)["input_ids"]
with self.lock:
tokens = yield from self._stream(ids, max_new_tokens)
yield {"event": "done", "tokens": tokens}
def auto_stream(self, state: str, name: str, question: dict, max_new_tokens: int = 512):
"""auto(), streamed. Yields {"event": "decision", ...} as soon as the quick decision and
the router's call are known, then {"event": "text"} reasoning pieces when escalated, then
{"event": "done", ...} carrying exactly what auto() returns."""
item = to_item(state, name, question)
with self.lock:
cache = StateCache(self.model, self.tpl.state_ids(item))
r = self._decide_on(cache, item)
out = {
"decision": self._answer(item, r),
"escalated": r["escalate"],
"state_prefills": 1,
}
yield {"event": "decision", **out}
if r["escalate"]:
ids = cache.ids + self.tpl.encode(chat_text(item))
tokens = yield from self._stream(ids, max_new_tokens, cache)
out["generation_tokens"] = tokens
out["generation"] = self.tok.decode(tokens, skip_special_tokens=True)
cache.kv.crop(len(cache))
yield {"event": "done", **out}
def chat(self, messages: list[dict], max_new_tokens: int = 512) -> list[int]:
ids = self.tok.apply_chat_template(
messages, add_generation_prompt=True, tokenize=True, return_dict=True
)["input_ids"]
with self.lock:
return self._generate(ids, max_new_tokens)
def auto(self, state: str, name: str, question: dict, max_new_tokens: int = 512) -> dict:
"""Decide; when the router flags it, decode from the same state cache."""
item = to_item(state, name, question)
with self.lock:
cache = StateCache(self.model, self.tpl.state_ids(item))
r = self._decide_on(cache, item)
out = {
"decision": self._answer(item, r),
"escalated": r["escalate"],
"state_prefills": 1,
}
if r["escalate"]:
ids = cache.ids + self.tpl.encode(chat_text(item))
out["generation_tokens"] = self._generate(ids, max_new_tokens, cache)
out["generation"] = self.tok.decode(
out["generation_tokens"], skip_special_tokens=True
)
cache.kv.crop(len(cache))
return out