Limits¶
What the models and Laya-Pro do not do well, measured where it could be. Benchmarks and known limits explains how the benchmarks were run, and BENCHMARKS.md has every run.
Laya-Pro's cache and servers¶
- The cache replays exact repeats only. Requests that mean the same but differ in form are new requests: reversing the option order changed 19-36% of decisions, and one trailing space changed up to 6.6%. Normalise requests before they reach the model if you need those to agree.
laya-serveand the MCP server do not install the cache yet. Build the app yourself to get one:create_app(router=Router(hooks=[DecisionCache(...)]))fromlaya.serve.- In that server, a hit waits for other clients' misses. It runs every request behind one lock, so a 0.7 ms hit took 169 ms while three other clients sent misses.
- Two threads predicting at once on an Apple GPU (MPS) abort the process. PyTorch's MPS backend is not safe for concurrent forward passes; run inference on one thread there. The cache's coalescing makes concurrent identical requests safe, since only one of them runs the model.
- int8 changes decisions. On Apple silicon it changed 10-36% of decisions and was no faster than
fp32 (ONNX) or 2.8x slower (PyTorch); PyTorch int8 lost 10.5 points of accuracy on MASSIVE
English. A
DecisionCacheshared by fp32 and int8 replays the first decision to both. - A model upgrade starts a new memory. The cache's key includes the model's fingerprint
(version, checkpoint, revision and temperatures), so decisions stored before an upgrade are not
replayed after it, unless you pin
fingerprint=.
Measurements: Real checkpoints on an Apple M4 Max.
Where Jev leads¶
- High-cardinality label spaces (>20 options at default settings): On Banking77, Jev scores 0.870 (on 72 labels) while Laya scores 0.425 (on 77 labels at default 256-token head budget). This is an architectural token-budget constraint: options share a fixed
head_max_lenbudget (192 tokens on English, 256 on multilingual), so 77 options receive only ~3 to 4 tokens per label, causing text to become indistinguishable. Jev supports up to 255 options out-of-the-box. Whilelaya-multilingualsupports 1,024 context (and up to 8,192 in the encoder) and you can raiseagent.cfg["head_max_len"] = 512at runtime, Jev is currently better suited for 50+ options in a single prompt without tuning.predict_shortlist(see Honest limits) keeps the topklabels with a caller-supplied embedding, then runs one forward pass on that shortlist. - Soft distribution matching: On typed-decisions, while Laya achieves higher argmax accuracy (0.766 vs 0.727), Jev achieves higher soft accuracy (0.580 vs 0.471) against the teacher's full probability distributions.
- Out-of-the-box raw calibration: Before temperature scaling, the base checkpoint has higher raw ECE (0.213 vs 0.144). Laya achieves its 0.081 ECE after domain temperature fitting.
Full detail, including every workflow and all 51 languages: BENCHMARKS.md.
Calibration¶
Both checkpoints are over-confident as shipped. Refitting one temperature per (question type,
option count) on held-out data moves mean ECE 0.466 -> 0.081 (laya) and
0.314 -> 0.106 (laya-multilingual). laya-multilingual ships with no fitted
temperatures at all, so fit them before relying on its probabilities.
At checkpoint load, numeric temperature entries are clamped to [0.5, 5.0]; invalid or
non-finite entries use the neutral fallback 1.0. A runtime warning reports the affected
entries and applied values. Bucket-specific temperatures still take precedence over per-type
values, including when a bucket uses the fallback. Raw values remain available in
agent.temperature_raw and agent.temperature_by_options_raw. A fallback prevents a loading
failure; it does not establish calibrated confidence.
Known limits¶
- The base checkpoints are near chance on typed-decisions zero-shot -- 0.362 and 0.352 against a 0.318 random baseline and a 0.461 majority-class baseline. The 0.766 figure comes from the checkpoint fine-tuned on that benchmark's own training split. Laya is a fast base to specialise, not a zero-shot decision engine.
- Avoid boolean-word labels in
choicequestions. Choice keys are rendered verbatim, and the current checkpoints can follow labels such astrue/falseoryes/noinstead of the option descriptions. Use semantic labels or opaque labels such asA/B, and validate them on the checkpoint and states you serve. - Semantic
choicelabels do not make negation safe. In the five cancellation examples from Laya issue #377, a CPU run on Laya 0.3.20 withno_action/cancel_accountkeys selectedcancel_accountfor all four negated requests onlayaand two onlaya-multilingual; one multilingual answer assigned it probability0.9998. The positive control passed on both checkpoints. These are narrow cancellation examples, not evidence that every negated state fails. Validate the exact checkpoint and wording you serve; using semantic keys alone does not avoid this failure. - High-cardinality choice questions and token budgets: Sequences split into an option prompt budget (
head_max_len) and the remaining document/state budget.head_max_lenis a cap, not the head length. The renderer fills the option prompt budget only as far as the question and its option descriptions need, andbuild_sequencethen sizes the state from the head it actually built (room = max_len - head_len - 1). The real room therefore depends on the question, not on the cap: laya(English) defaults to 512 context (head_max_len = 192). The shipped department question above renders a 48-token head, leaving 463 tokens for state, not 320.laya-multilingualandlaya-typed-decisionsdefault to 1,024 context (head_max_len = 256). The shipped question renders a 45-token head, leaving 978 tokens for state, not 768 (mmBERT-base encoder supports up to 8,192 with RoPE).max_len - head_max_lenis not a safe figure in either direction, so size a state tomax_len - head_len - 1for the question you are actually asking:- For a question that does not fill the cap it understates the room, as above — conservative, but it discards state the model would have read.
-
For a high-cardinality question it overstates it.
per = max(4, (head_max_len - 16) // k)floors at 4 tokens per option andhead_idskeeps a floor of 8, so past roughlyhead_max_len / 4options the head grows beyond the cap. A state sized to the cap-based figure is then truncated, not merely conservative:koptionshead real room cap-based figure laya,max_len=51277 319 192 320 laya,max_len=512100 411 100 320 laya-multilingual,max_len=1024100 411 612 768 The crossover is at
k ≈ head_max_len / 4and the gap widens from there;MAX_CHOICE_OPTIONS = 100inlaya/serve.pyputs the overstating regime within reach over HTTP.
At default settings, a 77-option question like Banking77 allocates only (256 - 16) // 77 ≈ 3–4 tokens per label, which causes accuracy to fall off sharply (0.425 vs Jev's 0.870). If evaluating 50+ options in a single question:
1. Raise agent.cfg["head_max_len"] = 512 and agent.cfg["max_len"] = 1024 (or up to 2048 / 4096 / 8192) so every option has enough tokens to remain distinct. Both are also per-request: predict(state, questions, head_max_len=512, max_len=1024) widens one question without changing the agent for everyone else, and every LangChain node takes the same two arguments (LangChain guide). laya --questions takes the same two budgets as --max-len / --head-max-len.
2. Or shortlist with embeddings and run one forward pass on the top k labels (predict_shortlist, example below). predict and system_one still score every criterion they are given.
3. Or split the label set yourself into a coarse question and a fine question.
4. Or let the decision model narrow the set itself: laya.predict_tournament(agent, state, questions) answers the labels in groups of at most 16, every group in the same forward pass, then asks the question again over the group winners, so up to 256 labels take two predict calls and no embedder. On the full test splits with the English checkpoint it took BANKING77 (77 labels) from 0.430 to 0.610, CLINC150 (150) from 0.625 to 0.876 and MASSIVE intent (60) from 0.515 to 0.569, with ECE between 0.06 and 0.14 instead of 0.30 to 0.37, at about twice the latency of one question (research/benchmarks/tournament). When the labels fit uncut in a raised budget, as MASSIVE's 60 do at head_max_len=512, raising it did better (0.622 against 0.590 on 500 rows). Probabilities on a tournament choice are over its finalists.
import laya
questions = {
"intent": {
"type": "choice",
"instructions": "Which banking intent is this?",
"criteria": {
"card_arrival": "where is my card",
"transfer_fee": "fee charged on a transfer",
# ...the rest of a large label set
},
}
}
result = laya.predict_shortlist(
agent,
{"text": "I was charged twice for a transfer"},
questions,
embed_fn=laya.embed_fn_from_agent(agent), # or any callable: texts -> (n, dim)
k=20,
)
result["shortlist"]["intent"]["labels"] # the top 20 labels sent to the model
embed_fn(texts) returns one vector per string. embed_fn_from_agent mean-pools the encoder already loaded on the agent; the decision head runs in the following predict / system_one call. Probabilities on a shortlisted choice are over those k labels. When k is at least the number of labels, the original question is passed through and embed_fn is not called. laya.shortlist_choice returns bare labels; pass return_scores=True for the (labels, scores) pair in rank order (None when nothing was dropped), the same cosines predict_shortlist reports in its shortlist metadata.
Shortlisting the same option set on every request re-embeds option texts that do not change. Wrap the embedder once with laya.cached_embed_fn(embed_fn) and repeat calls embed only the new query text: lookups are exact string matches into an LRU of at most 4,096 entries (about maxsize * dim * 4 bytes, so ~12 MB at the default with a 768-dim encoder), and texts missing from the cache are still embedded in one batched call. The wrapper's cache_info() reports hits and misses; call cache_clear() if the model behind embed_fn changes.
Laya issue #102 reports that a top-20 zero-shot shortlist moved a BANKING77 run from 54.3% to 60.8% on the reporter's setup. Those figures are the reporter's; this repository has not remeasured them.
- Ordinal
scorequestions are the weakest primitive (SST-5 0.372). noulcan follow its option labels instead of the state, most strongly onlaya(English).noulrenders its two options asfalse:/true:by default, and on the English checkpoint that label pair can dominate the answer, returning a confident "no" for clearly positive input (#156). Until a retrained checkpoint lands, checknoulanswers on your own data. You can override the model-facing pair while keeping thenoulresult as P(true):
{"type": "noul", "instructions": "Is this review positive?",
"criteria": {"true": "the review is positive", "false": "the review is negative"},
"labels": {"true": "A", "false": "B"}}
A noul with no criteria renders one generic option pair for every state (no, the statement
does not hold / yes, the statement holds). On laya that pair carries the whole decision, so
it answers "no" whatever the state — give a noul criteria if you need it to discriminate. On
laya-multilingual the criteria-less form does read the state.
Label sensitivity varies by checkpoint and state, so validate the override on your own data. A
two-option choice with neutral keys remains another workaround:
{"type": "choice", "instructions": "Is this review positive?",
"criteria": {"A": "yes, the review is positive", "B": "no, the review is negative"}}
criteria on a noul must be keyed true/false — those two keys are the option text the
model reads, so any other key is rejected instead of being quietly replaced with the defaults.
Before that check, criteria: {"yes": ..., "no": ...} was accepted, dropped, and answered
against false: / true: anyway, which cost 2 of 3 clearly positive reviews on the English
checkpoint (Laya issue #156). Use labels as above to
change the wording without touching the option text.
* laya-multilingual has a position bias on score questions (#131): it rarely picks the first-listed level, in any language. For English score questions, route to model="english", and for other languages validate score outputs on your own data before relying on them.
* action.act_probability carries no usable signal yet (#185). It reads 1.0 for almost every input, and its raw logits run against correctness (AUROC 0.30 on 396 labelled decisions). Gate on confidence instead, which reaches an AUROC of 0.77 on the same items.
* laya collapses outside English; laya-multilingual is weaker on English. Route, or pick
deliberately.