Files
laya/tests/test_predict_batch.py
Eric D. 76361c84ba feat(router): predict_batch — route-then-group multi-state scoring (follow-up to #47, with consistency harness + benchmarks) (#166)
* fix(router): score reordered-but-equal question schemas separately

Grouping by dict equality can merge two requests whose questions are equal
but keyed in a different insertion order; the group then scores the second
caller with the first caller's option order. Options are positional in
render_options, so that request's batched answers would not match its own
single-request answer -- the same failure mode as serialising questions to
JSON with sort_keys=True and reading them back. Group by an order-sensitive
schema signature instead (json.dumps with sort_keys=False and default=str,
matching render_criterion's tolerance), with a deterministic fake-agent
regression test.

* bench: consistency harness + benchmarks for the merged predict_batch

Point the harness at the merged Router.predict_batch (#138):

* tests/test_predict_batch.py -- tiny-checkpoint tests (CPU): agent-level
  batch==single, router-level mixed-language routing/order preservation, and
  the reordered-schema regression at the router layer
* benchmarks/bench_predict_batch.py -- --router now speaks the requests-dict
  API and adds router_consistency(): predict_batch vs one predict per request
  on 50/50 mixed EN/Hindi, the merge bar on the workload that regroups
  --consistency also writes its JSON now
* plot_results.py renders bench-rtx4070.png from the three result files

RTX 4070, laya 0.3.11, torch 2.6.0+cu124: agent layer 1.5-1.8x (<=64 states,
4 questions), router layer up to 3.4x (32 requests, 93 -> 313 states/s);
consistency 1800 agent answers + 600 router answers, 0 flips, max|dp| 2.2%.
2026-09-24 08:22:23 +05:30

189 lines
8.6 KiB
Python

"""Consistency tests for predict_batch: batched results must match single-request results.
Covers Agent.predict_batch and the merged Router.predict_batch (#138), including the
grouping edge that matters for the merge bar (batched == single): a question schema
that arrives with a different key insertion order must be scored with its own order.
Run: python -m pytest tests/test_predict_batch.py
Tiny local weights, no network required.
"""
import json
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from laya import load, Router # noqa: E402
from laya.common import DecisionModel # noqa: E402
def build_tiny_repo(repo: Path):
"""A tiny but valid checkpoint: Bert encoder + decision head, WordLevel tokenizer."""
from safetensors.torch import save_file
from tokenizers import Tokenizer
from tokenizers.models import WordLevel
from transformers import BertConfig, BertModel, PreTrainedTokenizerFast
repo.mkdir(parents=True)
config = BertConfig(vocab_size=6, hidden_size=64, num_hidden_layers=1,
num_attention_heads=2, intermediate_size=128)
config.save_pretrained(repo / "encoder")
tokenizer = PreTrainedTokenizerFast(
tokenizer_object=Tokenizer(WordLevel(
{"[PAD]": 0, "[UNK]": 1, "[CLS]": 2, "[SEP]": 3, "[MASK]": 4, "hello": 5},
unk_token="[UNK]")),
pad_token="[PAD]", unk_token="[UNK]", cls_token="[CLS]",
sep_token="[SEP]", mask_token="[MASK]",
)
tokenizer.save_pretrained(repo / "tokenizer")
model = DecisionModel(BertModel(config), head_layers=0)
save_file(model.state_dict(), repo / "model.safetensors")
(repo / "rl_agent_config.json").write_text(json.dumps({
"encoder": "unused/offline", "head_layers": 0, "act_costs": {"act": 0},
"max_len": 64, "head_max_len": 32,
}))
QUESTIONS = {
"intent": {"type": "choice", "instructions": "Which intent?",
"criteria": {"greet": "a greeting", "farewell": "a goodbye"}},
"polarity": {"type": "score", "instructions": "How positive?",
"criteria": ["negative", "neutral", "positive"]},
"mentions_hello": {"type": "noul", "instructions": "Does the state say hello?"},
}
STATES = ["hello", "goodbye", {"text": "hello there"}, "goodbye and hello"]
class PredictBatchTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
build_tiny_repo(Path(cls.tmp.name) / "repo")
cls.agent = load(str(Path(cls.tmp.name) / "repo"), device="cpu")
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
@staticmethod
def _flatten(result):
"""(qid -> (type, probabilities-or-value, confidence)) rounded, for comparison.
The argmax label is intentionally excluded: on the untrained tiny model the
choice probabilities sit near 0.5, and fp batch-shape noise may flip that
coin flip. The distribution is the invariant; the label is derivable from it.
"""
out = {}
for qid, ans in result["answers"].items():
if ans["type"] in ("choice", "score"):
probs = tuple(round(v, 5) for v in ans["probabilities"].values())
else:
probs = round(ans["noul"], 4)
out[qid] = (ans["type"], probs, ans["confidence"])
return out
def test_batch_matches_single(self):
"""Every batched answer must equal the single-request answer (fp32 CPU:
identical up to float tail noise, never a different decision)."""
batched = self.agent.predict_batch(list(STATES), QUESTIONS)
self.assertEqual(len(batched), len(STATES))
for state, result in zip(STATES, batched):
single = self.agent.system_one(state, QUESTIONS)
self.assertEqual(self._flatten(single), self._flatten(result),
"mismatch for state %r" % (state,))
self.assertEqual(result["usage"]["input_tokens"],
single["usage"]["input_tokens"])
def test_batch_sizes_are_consistent(self):
"""Adding more states to a batch must not change earlier answers."""
small = self.agent.predict_batch(list(STATES[:2]), QUESTIONS)
full = self.agent.predict_batch(list(STATES), QUESTIONS)
for a, b in zip(small, full):
self.assertEqual(self._flatten(a), self._flatten(b))
def test_empty_batch(self):
self.assertEqual(self.agent.predict_batch([], QUESTIONS), [])
def test_rejects_single_state(self):
with self.assertRaises(TypeError):
self.agent.predict_batch("hello", QUESTIONS)
class RouterPredictBatchTests(unittest.TestCase):
"""Router.predict_batch: route per request, group by (checkpoint, schema), share passes."""
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
repo = Path(cls.tmp.name) / "repo"
build_tiny_repo(repo)
for sub in ("multilingual",):
(repo / sub).mkdir()
for f in ("rl_agent_config.json", "model.safetensors"):
shutil.copyfile(repo / f, repo / sub / f)
shutil.copytree(repo / "tokenizer", repo / sub / "tokenizer")
shutil.copytree(repo / "encoder", repo / sub / "encoder")
cls.router = Router(models={"english": str(repo), "multilingual": str(repo / "multilingual")},
device="cpu", max_loaded=2)
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
@staticmethod
def _requests(states, questions=QUESTIONS):
return [{"state": s, "questions": questions} for s in states]
def test_mixed_language_batch_routes_and_preserves_order(self):
states = ["hello", # latin -> english
"नमस्ते", # devanagari -> multilingual
"goodbye"] # latin -> english
results = self.router.predict_batch(self._requests(states))
self.assertEqual(len(results), 3)
self.assertEqual([r["routing"]["model"] for r in results],
["english", "multilingual", "english"])
for result in results:
self.assertIn("answers", result)
self.assertEqual(set(result["answers"]), set(QUESTIONS))
# order preserved: same input -> same answer as calling predict() one by one
for result, state in zip(results, states):
single = self.router.predict(state, QUESTIONS)
self.assertEqual(PredictBatchTests._flatten(single), PredictBatchTests._flatten(result))
def test_equal_questions_with_different_option_order_match_single(self):
"""Regression for the grouping key: two requests whose questions are equal but
keyed in a different order must NOT share a group. Options are positional in
the rendered sequence, so the second request would otherwise be scored with
the first caller's option order and disagree with its own single-request
answer -- the failure mode reviewed out of the pre-#138 grouping keys that
serialised questions to JSON with sort_keys=True and read them back."""
ordered = {"intent": {"type": "choice", "instructions": "Which one?",
"criteria": {"zulu": "last alphabetically",
"mike": "in the middle",
"alpha": "first alphabetically"}}}
reordered = {"intent": {"type": "choice", "instructions": "Which one?",
"criteria": {"alpha": "first alphabetically",
"mike": "in the middle",
"zulu": "last alphabetically"}}}
requests = [{"state": "hello", "questions": ordered},
{"state": "hello", "questions": reordered}]
batched = self.router.predict_batch(requests)
self.assertEqual(len(batched), 2)
for req, result in zip(requests, batched):
single = self.router.predict(req["state"], req["questions"])
self.assertEqual(PredictBatchTests._flatten(single), PredictBatchTests._flatten(result))
# each label keeps the probability value its own option order produced
for qid in single["answers"]:
self.assertEqual(single["answers"][qid]["probabilities"],
result["answers"][qid]["probabilities"])
def test_empty_router_batch(self):
self.assertEqual(self.router.predict_batch([]), [])
if __name__ == "__main__":
unittest.main()