Schema-driven decisions¶
Turn a JSON schema, or a pydantic model, into Laya questions, and get back typed values with calibrated confidence. This is the bridge that makes Laya a structured-output engine: you describe the shape you want, Laya answers it in one forward pass.
import laya
schema = {
"type": "object",
"properties": {
"department": {"type": "string", "enum": ["billing", "support", "sales"],
"description": "Which team should handle this?"},
"urgency": {"type": "integer", "minimum": 0, "maximum": 2},
"needs_human": {"type": "boolean"},
},
}
agent = laya.load("convaiinnovations/laya")
values = agent.decide("I was charged twice, refund me.", schema=schema)
# {"department": "billing", "urgency": 2, "needs_human": True}
With pydantic (install laya[structured]):
from typing import Literal
from pydantic import BaseModel
class Ticket(BaseModel):
department: Literal["billing", "support", "sales"]
urgency: Literal[0, 1, 2]
needs_human: bool
ticket = agent.decide("I was charged twice, refund me.", schema=Ticket)
The supported subset¶
The top level must be an object with properties. Each property becomes one question.
Every row below is a real schema: tests/test_structured_docs.py compiles the first column and
asserts the question the compiler actually produces, so this table cannot drift from the code. A cell
is either a property schema on its own, a whole schema when the row needs its $defs (the field is
named field), or a call to an entry point.
| JSON schema | Laya question | Returned value |
|---|---|---|
{"enum": ["billing", "support"]} |
choice |
the chosen value, with its original type |
{"const": "billing"} |
choice |
that one value |
{"type": "boolean"} |
noul |
true / false |
{"type": "integer", "minimum": 0, "maximum": 5} |
score |
the highest-probability level, as an integer |
{"type": "number", "minimum": 0, "maximum": 5} |
score |
the level, as an integer |
{"type": "string", "enum": ["low", "high"], "description": "How urgent?"} |
choice |
How urgent? is the question wording |
{"anyOf": [{"enum": ["x", "y"]}, {"type": "null"}]} |
choice |
as the plain enum row; no answer leaves the key out |
{"oneOf": [{"type": "boolean"}, {"type": "null"}]} |
noul |
as the plain boolean row |
{"type": ["integer", "null"], "minimum": 1, "maximum": 3} |
score |
as the plain bounded-integer row |
{"properties": {"field": {"$ref": "#/$defs/Dept"}}, "$defs": {"Dept": {"enum": ["billing", "support"]}}} |
choice |
as the plain enum row; a local $ref is planned as the definition it names |
{"allOf": [{"enum": ["x", "y"]}]} |
choice |
as the plain enum row; the one-item wrapper pydantic v1 puts around a described $ref |
Literal[...] and Optional[...] are the pydantic spellings of the enum and anyOf rows:
questions_from_pydantic renders them to those shapes and the same rows apply. An Enum field
(str/int mixins and IntEnum included) is the $ref row, which needs the whole schema to show:
pydantic puts the members in $defs and points the property at them, so Optional[Dept] is an
anyOf around that $ref.
title is not read. pydantic puts one on every field of model_json_schema() whether you asked
for it or not, and a per-property name cannot label the per-option choices a question is built from,
so the wording lever is description — see How it maps internally below.
Projection is exact: an enum: [1, 2, 3] returns 2, not "2"; a bounded integer returns a
level between minimum and maximum; a boolean is noul >= 0.5.
Rejections¶
A schema that cannot be answered from a fixed option set raises laya.structured.SchemaError
(a ValueError) naming the exact path. Each row is executed too, with the field named name:
| property schema | message |
|---|---|
{"type": "string"} |
properties.name: a free string cannot be a fixed option set; use 'enum' or a boolean |
{"type": "array", "items": {"type": "string"}} |
properties.name: arrays are not supported; ask one field per element |
{"type": "object", "properties": {"inner": {"type": "boolean"}}} |
properties.name: nested objects are not supported; flatten the schema |
{"$ref": "#/definitions/node"} |
properties.name: $ref '#/definitions/node' does not resolve to an entry of this schema's '$defs' or 'definitions' |
{"enum": [1, "1"]} |
properties.name: enum values produce duplicate choice labels |
{"enum": []} |
properties.name: 'enum' must not be empty |
{"enum": "abc"} |
properties.name: 'enum' must be an array of values, got str |
{"type": "number"} |
properties.name: a numeric field needs integer 'minimum' and 'maximum' to become a score |
{"type": "integer", "minimum": 5, "maximum": 2} |
properties.name: 'maximum' 2 is below 'minimum' 5 |
{"type": "integer", "minimum": 0, "maximum": 10} |
properties.name: 11 levels exceeds MAX_SCORE_LEVELS=10; narrow the range or use an enum |
{"anyOf": [{"type": "string"}, {"type": "integer"}]} |
properties.name: only 'Optional[...]' unions (one non-null branch) are supported, got 2 |
{"type": ["string", "integer"]} |
properties.name: 'type' has multiple non-null types; unions are not supported |
{"format": "date"} |
properties.name: unsupported schema {'format': 'date'} |
"boolean" |
properties.name: property must be an object, got str |
The entry points themselves reject these; the last row needs a whole schema, because a $ref cycle
runs through its $defs:
| call | message |
|---|---|
plan_from_json_schema("not a schema") |
expected a JSON schema object, got str |
plan_from_json_schema({"type": "object"}) |
the top level must be an object with 'properties' |
plan_from_json_schema({"type": "object", "properties": {}}) |
'properties' must be a non-empty object |
plan_from_json_schema({"type": "object", "properties": {"p%d" % i: {"type": "boolean"} for i in range(33)}}) |
33 properties exceeds MAX_PROPERTIES=32 |
plan_from_json_schema({"type": "object", "properties": {"name": {"enum": ["v%d" % i for i in range(33)]}}}) |
properties.name: 33 options exceeds MAX_OPTIONS=32 |
decide(None, "I was charged twice.", schema=42) |
expected a JSON schema dict or a pydantic model, got int |
plan_from_json_schema({"type": "object", "properties": {"name": {"$ref": "#/$defs/Loop"}}, "$defs": {"Loop": {"$ref": "#/$defs/Loop"}}}) |
properties.name: $ref '#/$defs/Loop' is recursive; flatten the schema |
Limits: MAX_PROPERTIES = 32, MAX_OPTIONS = 32, MAX_SCORE_LEVELS = 10.
The API¶
| function | purpose |
|---|---|
laya.decide(runner, state, schema=..., *, questions=..., return_details=..., min_confidence=..., **predict_kwargs) |
the free function, works for Agent and Router |
agent.decide(state, schema=..., ...) / router.decide(state, schema=..., ...) |
convenience methods |
laya.decide_batch(runner, states, schema=..., ...) / agent.decide_batch(...) / router.decide_batch(...) |
the same over many states, one batched call |
questions_from_json_schema(schema) |
schema to Laya questions |
questions_from_pydantic(model) |
pydantic model to questions (requires pydantic) |
answers_to_json(answers, schema) |
project raw answers onto schema values |
answer_to_pydantic(model, answers) |
project raw answers into a pydantic instance |
plan_from_json_schema(schema) |
the validated field plan (advanced) |
Pass exactly one of schema or questions. With questions, decide returns the raw answers
instead of projecting. Extra keyword arguments are forwarded to predict, so hooks, model=,
task= and the token budget all work:
Scoring many states¶
decide_batch is the throughput form: the schema is planned once and its questions run over every
state through predict_batch, so states share forward passes instead of one per call. Results come
back in input order, projected exactly as decide projects, and return_details=True gives one
DecisionResult per state:
values = agent.decide_batch(ticket_texts, schema=Ticket) # values[i] matches ticket_texts[i]
results = router.decide_batch(states, schema=Ticket, return_details=True, batch_size=64)
On a Router each state is still routed on its own, so one call can span checkpoints. Which
keywords reach predict_batch depends on who reads them. The call-level ones — batch_size=,
hooks=, sort_by_length= — are passed as keywords to predict_batch. The routing pins and the
token budget — model=, task=, lang=, lang_guess=, max_len=, head_max_len=, the tuple
laya/structured.py::_ROUTER_REQUEST_KEYS names — are read off every request by a Router, whose
predict_batch declares none of them, so decide_batch copies them onto each request instead; that
is why decide_batch(states, schema=..., model="multilingual") pins a checkpoint the way decide
does. On an Agent-like runner the split is visible: its predict_batch declares batch_size=,
hooks= and lang= but no model= and swallows nothing, so model= there is a TypeError, not a
silent extra keyword. Agent, ONNXAgent and Router all have it; a runner without predict_batch raises
TypeError rather than silently falling back to a loop — call decide per state there. Batching can shift borderline argmaxes the
same way predict_batch does; the README records the measured speedups for both devices.
Confidence and probabilities¶
By default decide returns only the values. Pass return_details=True for a DecisionResult
with per-field confidence, probabilities, the raw answers, and the usage and routing of the call:
result = agent.decide(state, schema=Ticket, return_details=True)
result.values["department"] # "billing"
result.answer_confidence["department"] # 0.94 max(p): the quantity min_confidence gates on
result.confidence["department"] # 0.79 1 - H(p)/log(k) on this 3-option `choice` field
result.probabilities["department"] # {"billing": 0.94, "support": 0.06, "sales": 0.0}
result.usage # {"input_tokens": ..., "output_tokens": 0,
# "state_tokens": ..., "state_tokens_dropped": ...,
# "truncated": ..., "truncated_questions": [...]}
result.routing # the Router decision, when a Router answered
usage is the block predict() built, forwarded whole: input_tokens sums the state over one
row per question and output_tokens is always 0 because nothing is generated, while the other
four keys are the truncation report (#174) -- state_tokens is what the whole serialized state
needs, state_tokens_dropped the most of it any one question's head gave up, and truncated /
truncated_questions say which. A seventh key, options, is present only when the head budget
left some question's options sharing a token span (#538); docs/http-api.md documents both this
block and that field as response keys, and tests/test_structured_docs.py holds this page's list
to the code that builds it.
confidence and answer_confidence are different quantities, and the names follow the definitions.
answer_confidence is max(p), the probability mass on the answer being reported. It is what
temperature scaling fits, what every calibration figure in this repository is computed on, and what
min_confidence is compared against — which is the reason to gate on it rather than on
confidence. confidence is not one formula. On a choice or a score it is normalized entropy,
1 - H(p) / log(k), whose scale moves with the number of options the question had. On a noul it is
max(p_true, 1 - p_true), the probability of the side being reported, which over two options is
max(p). tests/test_confidence.py pins a two-option distribution as 0.90 on a noul against 0.53
on an equivalent choice; 0.53 is what entropy gives at k = 2, so the 0.90 is the second formula and
not the first at a smaller option count. The schema above mixes both — department is a choice,
needs_human a noul — so one DecisionResult.confidence dict can hold two scales at once, which is
why neither reading compares against a threshold you have not measured per type. A field that reported
no usable answer_confidence maps to None, which is not the same as a reported 0.0.
Gate on the same quantity the gate uses:
answer_confidence being the right number to filter on is not the same as it being a trustworthy
probability. Reading it as "about c of the answers returned at c are correct" holds only after
temperatures have been fitted and validated on held-out data for that checkpoint and question shape.
The shipped checkpoints are over-confident as shipped and laya-multilingual ships with no fitted
temperatures at all — see the README's
Calibration and
Honest limits sections, and the
fine-tuning notebook
for the fitting loop. Fit before you rely on the level; report it because it is the quantity the
gate and the eval harness both use.
How it maps internally¶
- Enum and
Literalbecomechoicequestions with the values as string labels; the label is mapped back to the original value on the way out, so integers stay integers. - A bounded integer becomes a
scorequestion with one level per value; the returned value isminimum + argmax. - A boolean becomes a
noulquestion; the value isnoul >= 0.5. - A
descriptionbecomes the question instructions, so a good description is what makes the decision accurate. This follows the same rule as the hooks guide: be explicit about what each option means. - A local
$ref(#/$defs/...or#/definitions/...) is replaced by the definition it names before the field is planned, so a pydanticEnumasks exactly the question the matchingLiteralasks and projects to the member's value, whichanswer_to_pydanticturns back into the member. The property's own keys sit on top and the definition'sdescriptionis dropped: pydantic fills it from the enum's docstring (pydantic v1 with "An enumeration." when there is none), which describes the type rather than the question, so the wording is still the field'sdescription. A ref to another document, a missing entry or a cycle raises, and a ref to a model is a nested object. - A
nullbranch is dropped before the field is planned, soOptional[X]asks exactly the questionXasks. The field's key is simply absent from the values when there is no answer for it, which is what makes it safe to declare a field optional without changing what the model sees.
See also¶
- Prediction hooks: observe, shape, cache or gate the decisions this produces.
- Decision primitives:
choice,scoreandnoulin depth. - LangChain and LangGraph:
LayaDecisionis this call as a runnable in a chain.