Decision consistency¶
The figures on this page describe the archived 0.3.20 runs identified above. The October review records checks against the current fork and clarifies local checkpoint identity and retention limits.
The same request can come back with a different answer for two unrelated reasons: the model varies between runs, or something around the model changed. Laya's forward pass samples nothing, so the first reason does not apply to it on a fixed setup, but the second one does. This page shows how to see how close an answer is to flipping, and how to make a repeated request get the answer it got the first time.
What moves an answer¶
A hosted decision API is re-evaluated on every call. In the archived Feishu comparison, the same
64 requests were sent three times to Jev 1.13.0 and to laya-multilingual:
| identical across three runs | Jev 1.13.0 | Laya |
|---|---|---|
whole response, probabilities and confidence included |
29 of 128 (23%) | 128 of 128 |
choice probabilities |
38 of 64 | 64 of 64 |
| yes/no values of the four-question mode | 0 of 64 | 64 of 64 |
| the decision the benchmark acts on (label) | 127 of 128 | 128 of 128 |
Jev's yes/no values moved by a median of 0.01 and at most 0.08 between runs, and in 9 answers its
confidence changed while the probabilities it printed, to two decimals, did not. The one label
that changed (cross_chat-03: todo, todo, noise) was the least decided answer in the set:
0.53 against 0.44, then 0.51 against 0.44, then 0.45 against 0.50.
Laya returned the same bytes every time. What can still move one of its answers:
- Hardware and precision. CUDA runs in bf16 or fp16 autocast, and MPS in fp16 from five rows up, so the same request on another device can differ in the last digits.
- Batch shape. Padding a state next to longer ones changes floating-point summation order;
predict_batch(..., sort_by_length=True)says so in its docstring. - The model. A new revision, a refitted temperature, or a
max_lenchange is a different model. - The request. Option order is positional, so reordered options are a different request (Laya issue #166); on 20 options the answer changed for 15-23% of permutations (option-order robustness).
Each of these only flips an answer that sits close to its decision boundary.
How close an answer is to flipping¶
decision_margins reads a result and returns, per question, how far the answer is from flipping,
from 0 on the boundary to 1:
from laya import Router, decision_margins
router = Router()
result = router.predict(state, questions)
margins = decision_margins(result, noul_threshold={"urgent": 0.75})
borderline = [name for name, margin in margins.items() if margin < 0.1]
For choice and score the margin is the gap between the two most likely options. For noul
it is twice the distance from P(yes) to the threshold you act on (0.5 unless you say otherwise),
which at 0.5 is the gap between P(yes) and P(no). It needs nothing but the payload, so it reads a
Jev-compatible response the same way.
On the archive, three of Jev's answers changed between runs, and a margin under 0.1 marked all three in every run, while marking 26 of the 960 answers it gave across the runs (2.7%). A model that varies between runs does know when it is close. What it cannot know is which side the next run lands on: each probability it reports is itself one draw.
For Laya the margin means something else, because its answers do not move between runs: it says
how likely the answer is to be wrong. On the same archive, which ran the base multilingual
checkpoint with no fine-tuning, 1 of the 14 choice answers with a margin under 0.05 was right,
against 14 of the 36 with a margin of 0.2 or more. Send low-margin answers to review or to a
fallback rather than acting on them.
Replaying decisions¶
DecisionCache is a prediction hook that stores every decision under a hash of
the request and replays it when the same request comes again:
from laya import DecisionCache, Router
cache = DecisionCache("decisions.sqlite", ttl=30 * 24 * 3600) # or DecisionCache() for memory
router = Router(hooks=[cache])
router.predict(state, questions) # runs the model and stores the decision
router.predict(state, questions) # the stored decision, no forward pass
- Key. A 16-byte BLAKE2b hash of the state, the questions in their given order, the per-call token budget, the checkpoint, and the model's fingerprint: its id, revision, calibration temperatures, configured token budget, subfolder and the Laya version. Unversioned local checkpoints also use artifact identity captured at load: verified digests when supplied, otherwise file size, modification time and inode. File metadata detects ordinary replacements; it does not verify contents or provide portable identity across machines. When the checkpoint has per-language temperatures the routing decision is part of the key too, because that is the one way the language reaches an answer. The device is not part of it, so a fleet of mixed hardware shares one set of decisions.
- The first decision wins. A request that missed at the same time as another, in another
thread, process or machine sharing the store, returns the decision stored first instead of its
own, and
cache_info()["conflicts"]counts it. Every caller of a request gets one answer while its entry lives. - Retention. Each decision carries its own expiry.
ttlis the age in seconds after which a decision is answered afresh,Nonefor never, or a function of the question set and the answer (below).maxsize(100,000 by default) is the most decisions the built-in stores hold; past it the least recently stored or renewed leave first, andprune()drops the expired ones on demand. Memory capacity is enforced on each write. SQLite checks the size bound periodically to amortize maintenance, so it can temporarily exceedmaxsize, especially with several processes writing. Callprune()when a maintenance task must enforce the bound immediately. - Retention from last use. With
renew_on_hit=Truea replay pushes the expiry out again, so a decision lasts as long as its request keeps coming and only one that stops coming expires. The renewal is written once the decision has aged a sixteenth of its lifetime, at most 16 times per lifetime, so a hit still almost never writes. - Upgrades. A new revision or calibration changes the fingerprint and starts a fresh set of
decisions. Pass
fingerprint="policy-v1"to keep replaying the old ones across upgrades until they expire, when repeatability matters more than the new model. - What is stored. Hashes and the answers, as compact JSON, compressed when that is smaller. No request text is written.
A ttl function sets retention per question set or per answer. It receives the questions and the
answer payload, and returns seconds, None to keep the decision for ever, or 0 not to keep it:
from laya import DecisionCache, decision_margins
DAY = 24 * 3600
def retention(questions, result):
if "fraud" in questions:
return 7 * DAY # re-check fraud decisions weekly
if min(decision_margins(result).values(), default=1.0) < 0.1:
return 90 * DAY # close calls are the ones a recompute could flip
return 30 * DAY
cache = DecisionCache("decisions.sqlite", ttl=retention, renew_on_hit=True)
Sharing decisions between machines¶
The SQLite file is shared by the processes of one machine. To share one set of decisions across
machines, pass store= any object that follows laya.DecisionStore: get, add, renew,
prune, __len__, clear and close, over bytes keys and values with an expiry in Unix
seconds. The one rule a store must keep is in add: when a key already holds an unexpired
value, keep it and return it; that is what makes the first decision stored the one every machine
replays. Laya ships no network store itself, so it keeps depending on nothing hosted.
examples/hooks/decision_store_redis.py
is one over Redis, using SET ... NX for that rule and Redis's own expiry:
import redis
from laya import DecisionCache, Router
from decision_store_redis import RedisDecisionStore
store = RedisDecisionStore(redis.Redis(host="cache.internal"))
router = Router(hooks=[DecisionCache(store=store, ttl=30 * 24 * 3600, renew_on_hit=True)])
Run the example file to check it against a Redis server (REDIS_URL) or fakeredis: two Routers,
standing for two machines, answer one request, and the second replays the first one's decision
without running its model.
A store that fails never fails a prediction. When a call raises (Redis cannot be reached, a
SQLite file is locked or its disk is full) or a stored value cannot be decoded, that request is
answered by the model as if there were no cache, and a value that cannot be decoded is replaced
by the new decision. The first failure warns, and cache_info()["errors"] counts them, so watch
it where decisions must stay consistent: while the store is down, repeats are recomputed.
Concurrent identical requests¶
When the same request arrives on several threads at once, the first one to miss runs the model
and the others wait for its decision instead of computing their own. A burst of retries or a
fan-out then costs one forward pass. In the test suite, eight threads sending one request
together over a model that took 0.3 s ran it once; the other seven were hits, and
cache_info()["coalesced"] counts the ones that waited. A state repeated within one
predict_batch is computed once and copied to its repeats.
A request never waits on itself, so this cannot deadlock. It does not wait for the other states
of its own batch, under hooks_concurrent=False (hooks hold a lock there) or under
hooks_timeout (hooks run on a helper thread). An owner whose forward pass fails releases its
waiters at once, and they compute for themselves. One that hangs holds them for at most five
minutes, after which its claim is dropped. Different requests never wait for each other. Waiting
is per process; across processes and machines the first decision stored still wins.
DecisionCache(coalesce=False) turns waiting off. A miss costs about 15 µs more with it on, next
to hundreds of milliseconds for the forward pass it can save.
One cache for every backend¶
The device, the precision and the runtime are not part of the key. A decision stored by the
PyTorch fp32 Agent is therefore replayed by an int8 Agent or an ONNXAgent of the same
checkpoint, as long as both are loaded by the same id or path, which is part of the fingerprint.
You can serve the fast int8 path and still give every repeated request the answer it got first,
whichever backend computed it. On the synthetic checkpoint, the three backends' own answers
differed, and over one SQLite file int8 and ONNX replayed the fp32 decision in 0.48 and 0.28 ms,
against 589 ms to compute it:
Loading one backend from a local path and another from the Hub id gives two fingerprints. Pass
the same fingerprint="..." to both caches to share their decisions anyway.
Pre-warming¶
Requests you know are coming, such as a backlog, yesterday's traffic or a fixed evaluation set, can be answered before anyone asks. Run them through a Router with the cache installed, in batches, on the fastest hardware you have; production then only replays:
cache = DecisionCache("decisions.sqlite", ttl=30 * 24 * 3600, renew_on_hit=True)
router = Router(hooks=[cache])
for chunk in (requests[i:i + 64] for i in range(0, len(requests), 64)):
router.predict_batch(chunk) # [{"state": ..., "questions": ...}, ...]
Warm with the same questions, token budget and checkpoint that production will send, or the keys will not match. A GPU machine can warm the file that CPU machines then serve from, since the device is not part of the key.
A cache makes repeats identical; it does not make a borderline answer right, and it keeps a wrong
one until it expires. It matches exact requests only, so a state that differs by one character is
a new request. On an Agent with per-language temperatures it stands aside, because a hook
cannot see the lang a call passed; install it on the Router there.
Voting¶
Asking a model several times and taking the majority reduces run-to-run variation; it does not remove it. If one run flips with probability q, a three-run majority still flips with probability 3q² − 2q³: 26% for a q of ⅓. Averaging the probabilities of the runs is better than counting votes, and gating on the margin keeps the cost down: on the archive, re-asking Jev only when the margin is under 0.1 would have re-asked 2.7% of answers.
Re-running Laya on the same input returns the same bytes, so a vote over repeats is a vote of
identical ballots. A vote over equivalent requests is not: averaging the probabilities over
the rotations of a choice question's options, sent as one predict_batch, cancels the option
position bias above. Measure it on your own data before relying on it:
def rotated_vote(router, state, name, question):
options = list(question["criteria"].items())
requests = [{"state": state, "questions": {name: dict(question, criteria=dict(options[i:] + options[:i]))}}
for i in range(len(options))]
runs = [r["answers"][name]["probabilities"] for r in router.predict_batch(requests)]
mean = {label: sum(run[label] for run in runs) / len(runs) for label, _ in options}
return max(mean, key=mean.get), mean
Cost¶
A hit skips everything from tokenization to decoding. On a 4-core CPU a hit over an instant model took 73, 76 and 167 µs for 140, 2,000 and 20,000-character states, against hundreds of milliseconds to seconds through the model; the Router remembers the language detection of states it has seen, which was most of a hit's cost before. Hashing the request costs 16-84 µs depending on its length, a SQLite lookup 7.5 µs, and a stored decision takes about 320 bytes on disk. Storing happens only on a miss and takes 45 µs, which is why it is not deferred to a background thread. Where a request's time goes has the full measurements.