Typed Decisions (Laya)
A decision model answers a bounded question about a state with a typed result instead of generating text: one option out of a list (choice), a level on an ordinal scale (score), or the probability that a statement is true (boolean probability). The result carries a confidence and takes milliseconds of encoder time, not seconds of decoding. The three modules below put that on the JVM path with the same contract for every model.
Coordinates
dependencies {
implementation(platform("sk.ainet.transformers:skainet-transformers-bom:0.63.3"))
implementation("sk.ainet.transformers:skainet-transformers-decisions-api") // the contract, no engine dependency
implementation("sk.ainet.transformers:skainet-transformers-decisions-runtime") // validation + acceptance policy
implementation("sk.ainet.transformers:skainet-transformers-inference-laya") // Laya as a DecisionAdapter
}
The contract (sk.ainet.decisions)
A DecisionRequest names a model, a state and the questions:
val request = DecisionRequest.builder("ticket-4711")
.model("laya-multilingual")
.state("Customer writes: my invoice shows the old address although I changed it last week.")
.question(ChoiceQuestion("department", "Which department handles this request?", listOf(
ChoiceOption("billing", "billing and invoices"),
ChoiceOption("account", "account data and profile"),
ChoiceOption("shipping", "delivery and logistics"),
)))
.question(ScoreQuestion("urgency", "How urgent is this request?", listOf(
ScoreLevel("can wait"), ScoreLevel("this week"), ScoreLevel("today"), ScoreLevel("now"),
)))
.question(BooleanProbabilityQuestion("escalate", "Should a human look at this request?",
BooleanCriteria(whenFalse = "no", whenTrue = "yes")))
.build()
The state is DecisionState.text(…) or DecisionState.structured(jsonElement); the
InferencePolicy decides whether an over-long state is rejected (REJECT, the default) or
truncated (TRUNCATE_STATE). A request fails before any inference when an option set is
invalid, a primitive is unsupported or the option count exceeds the adapter’s capabilities.
Every question ends in exactly one QuestionOutcome:
|
|
|
The policy did not accept the candidate: |
|
|
DecisionResult.provenance records the model id, backend and precision, the preprocessing
version, the calibration status, the policy id and version, the duration and one
QuestionTrace per question (sequence length, state tokens kept and dropped).
The runtime (sk.ainet.decisions.runtime)
val agent = LayaModelLoader.load(Path.of("/models/laya/multilingual"), ctx, exactGelu = true)
val runtime = DecisionRuntime(listOf(LayaDecisionAdapter(agent, modelId = "laya-multilingual")))
val policy = AcceptancePolicy(
id = "triage", version = "1",
choice = ChoiceAcceptance(minConfidence = 0.2, minMargin = 0.1),
score = ScoreAcceptance.MinTopProbability(0.35),
boolean = BooleanAcceptance(lower = 0.4, upper = 0.6),
)
runtime.openSession(ModelSelector.Installed("laya-multilingual")).use { session ->
val result = session.decide(request, policy)
when (val outcome = result.question("department")) {
is QuestionOutcome.Accepted -> println((outcome.decision as Decision.Choice).optionId)
is QuestionOutcome.Abstained -> println("unclear: ${outcome.reason}")
is QuestionOutcome.Failed -> println("failed: ${outcome.error}")
}
}
Thresholds are inclusive. A choice is accepted when its entropy confidence
(1 − H(p) / log k) reaches minConfidence and the gap to the runner-up reaches minMargin;
a score when the mass on its top level reaches MinTopProbability (or its normalized entropy
stays under MaxNormalizedEntropy); a boolean when P(true) lies outside the open interval
(lower, upper). Without a policy every question abstains with NO_POLICY. AcceptanceEngine
is public, so a caller can re-apply a stricter policy to the recorded candidates.
DecisionRuntime holds one adapter per installed model id and validates every request against
the adapter’s AdapterCapabilities (primitives, languages, context and option budgets, maximum
questions per request) before the adapter sees it. The conformance tests in
decisions-runtime run against a FakeAdapter and are the contract any adapter must satisfy.
The DSL (sk.ainet.decisions.dsl)
Like the NN DSL and the data pipeline DSL, the decision contract has a Kotlin DSL that produces the same data classes as the constructors above; nothing in a block exists that the request model cannot express, so serialisation and provenance are unaffected. Three entry points:
import sk.ainet.decisions.* // the contract and the typed readback
import sk.ainet.decisions.dsl.* // decisionSchema, decisionRequest, acceptancePolicy
import sk.ainet.decisions.runtime.* // openSession(schema), decide(schema, ...)
// Questions and policy declared once, states supplied later.
val triage = decisionSchema("commit-triage") {
model("laya-multilingual")
choice("kind", "What kind of change is this commit?", "feature", "bugfix", "refactor", "docs", "test", "chore")
score("risk", "How risky is this change for the team?", "trivial", "low", "moderate", "high", "critical or breaking")
probability("standup", "Is this commit worth mentioning in the team's daily stand-up?")
probability("adr", "Does this commit contain or imply an architecture decision?") {
whenFalse = "no decision"; whenTrue = "a decision is made or implied"
}
policy(version = "1") { // id defaults to the schema id
choice { minConfidence = 0.2; minMargin = 0.1 }
score { minTopProbability(0.35) }
boolean { abstainBetween(0.4, 0.6) }
}
}
runtime.openSession(triage).use { session ->
for (commit in commits) {
val result = session.decide(triage, "commit-${commit.id}", commit.message)
println("${commit.id}: ${result.summary()}") // kind=bugfix risk=1 standup=? adr=false
if (result.boolean("standup") == false) continue
val kind = result.choice("kind") ?: "unclear"
}
}
// One request, inline.
val request = decisionRequest("ticket-4711") {
model("laya-multilingual")
state("Customer writes: my invoice shows the old address although I changed it last week.")
choice("department", "Which department handles this request?") {
option("billing", "billing and invoices")
option("account", "account data and profile")
option("shipping", "delivery and logistics")
}
score("urgency", "How urgent is this request?", "can wait", "this week", "today", "now")
probability("escalate", "Should a human look at this request?")
truncateState()
}
// A policy on its own.
val strict = acceptancePolicy("triage", "2") { choice { minConfidence = 0.5 } }
-
decisionSchema(id) { }fixes the questions, the policy, the model and the inference policy and stamps out oneDecisionRequestper state withschema.request(requestId, state);DecisionRuntime.openSession(schema)andDecisionSession.decide(schema, requestId, state)indecisions-runtimeuse them directly, the schema policy applying unless a call overrides it. -
decisionRequest(id) { }builds a single request;acceptancePolicy(id, version) { }a policy. Ascore { }block takes exactly one criterion andboolean { }needs both thresholds, otherwiseInvalidPolicyException; a primitive without a block keeps abstaining withNO_POLICY. -
choice/scoreaccept a block (option(id, description),level(description)) or a vararg of ids whose text doubles as the description;probabilitytakes the plainno/yescriteria unless its block setswhenFalse/whenTrue. -
Typed readback on
DecisionResult:choice(id),score(id),boolean(id)return the accepted value ornullfor an abstention or failure and throw for an unknown id or another primitive;probabilityTrue(id)reads the accepted or abstained candidate;outcome.candidate,accepted(id),isCompleteand the one-linesummary()complete the set.
The scopes carry a @DslMarker, so a nested block cannot call the functions of an enclosing
scope by accident.
Laya (sk.ainet.models.laya)
skainet-transformers-inference-laya wraps the Laya checkpoints (Apache 2.0): the multilingual one
(convaiinnovations/laya, an mmBERT-base encoder, 22
layers, hidden 768) and the English one
(convaiinnovations/laya-typed-decisions,
ModernBERT-large, 28 layers, hidden 1024) — a ModernBERT-family encoder (GeGLU, RoPE on every layer,
a local attention window on two of every three layers) followed by a type embedding, a two-layer
decision head and a scorer. Every size comes from the checkpoint’s own encoder/config.json. The port follows the reference build_sequence exactly, including CPython
json.dumps float formatting for structured states (PythonJson), the 48-token option cap and
the state-type-dependent truncation direction.
-
LayaCheckpointFiles.read(dir)readsrl_agent_config.json,encoder/config.jsonand the tokenizer pair;LayaModelLoader.load(dir, ctx)addsmodel.safetensors(F16, up-cast to FP32) and returns aLayaAgent. -
LayaDecisionAdapter(agent, modelId)is theDecisionAdapter. Option ids are the model-facing keys; the descriptions are what the model reads. Calibration isUNVERIFIEDunless the checkpoint ships temperatures. -
Exact (erf) GELU is required for parity with the reference (
exactGelu = true, the engine’sgelu(approximate = false)since SKaiNET 0.59); the tanh approximation moves the predictions by about 7e-4.
Verification against the fixtures shipped with the checkpoint (copied under
llm-inference/laya/src/jvmTest/resources/laya-fixtures/, Apache 2.0): 26 of 26 packed
sequences, the 40-text tokenizer corpus (CJK, RTL, combining marks, emoji, literal <mask>),
and all six predict.json cases within 5e-5. The model-gated tests run when
LAYA_MULTILINGUAL_PATH points at the checkpoint directory.
Expert heads and routing
A Laya checkpoint is an encoder plus a small decision head (type embedding, two transformer
blocks, scorer), and the head is what a publisher fine-tunes for a domain: the encoder stays
frozen, the head is copied, trained on domain data and saved on its own as expert.safetensors
(the type_emb., head., scorer. tensors, act_head. ignored) with an expert.json
(name, domains, its own calibration temperatures). sk.ainet.models.laya runs any number of such
heads on one encoder:
-
LayaExpertLoader.load(metaJson, weights, base, ctx)binds an expert head for a loaded baseLayaAgent(weightFormFor = LayaWeightForms.q8_0Forpacks its projections like the base’s);LayaExpertholds theLayaDecisionHeadand aLayaDecoderwith the expert’s temperatures over the base agent’s budgets.LayaWeightPreparation.prepareHead/LayaDecisionModel.buildHeadare the steps underneath; a head prepared on its own reproduces the base model bit for bit. -
LayaDecisionModel.logitsFrom(encoded, sequence, type, head)runs a head on an encoder output: the encoder runs once per question (the sequence contains the question) and every head that should answer it reads the same hidden states.LayaAgent.encodeFor/logitsFromdo the same over the padded batch plan. -
LayaRouterSpec(question, options, experts, threshold)is the prompted router: the base head answers a choice over fine-grained kinds of text (each with a description) about the state, every kind maps to an expert, and the expert answers when it is loaded and the top probability reaches the threshold — otherwisegeneral, the base head. Routing is sequence-level, one head per request; this is not a sparse mixture of experts. -
LayaMixtureAgent(base, experts, router).decide(state, questions, expert = null, compare = false)routes (one extra encoder pass for the router question) and answers with the chosen head;expertforces a head (the routing is still reported as overridden),compareadds the base head’s answers on the same encoder output. The result carries the routing (probabilities over the kinds, the pick, the threshold), the answers, and encoder / head milliseconds. -
LayaMixtureDecisionAdapter(mixture, modelId, onRouted)is theDecisionAdapterof a mixture: every request is routed once and answered by the chosen head with its calibration; the contract has no field for the head that answered, soonRoutedreports it.
The gated LayaMoeEvalTest replays the published evaluation of
layaMOE (Apache-2.0; safety and customer_ops heads on
convaiinnovations/laya-typed-decisions, 108 hand-labelled cases) and requires the same routing
and the same answers as the PyTorch FP32 run; it needs LAYA_TYPED_DECISIONS_PATH and
LAYA_MOE_EXPERTS_PATH.
Quantized lane
LayaModelLoader.load(files, weights, ctx, weightFormFor = LayaWeightForms.q8_0For) loads the
encoder and head projections and the token table as Q8_0 at load (engine 0.61 quantize-at-load,
SKEEP-007): about a quarter of the FP32 bytes, served by the packed kernels on every tier — on the
JVM the FFM pack, on Android the JNI pack, in a browser the skainet-backend-wasm-simd sidecar after
WasmSimd.install(). The contract of that lane is decision quality rather than FP32 parity:
LayaQuantizedParityTest requires every predict.json choice to agree with the FP32 agent and no
probability, score, noul value or confidence to shift by more than 0.05 (measured: 5/5 choices,
max shift 0.035). On the eager JVM path this lane trades speed for memory — the packed kernels run
one activation row at a time — so use it where the bytes matter: the browser, a phone, a small heap.
Current limits
-
The loader is common code (a
RandomAccessSourceplus the four side files as text); obtaining the bytes — a file, an app’s files directory, a download held in memory — is the caller’s part. -
Eager FP32 on the CPU path by default; batching across the questions of one request since 0.61 saves about 10 % per question there. The compiled lane is an open item; the quantized lane is the section above.
-
The base checkpoint is a starting point the publisher expects to be specialised; keep option sets small (six or fewer) and treat abstentions as the normal case, not the exception.