Jev.Nx
Open decision models as a Jev backend, running in-process on Nx.
TypeSafe Jev made typed decisions with calibrated
probabilities a model class of its own, and open models followed it. This
package runs them where your Elixir code runs and answers a Jev.Server
exactly as Jev does, so the same handle_answer/3 clauses, thresholds, and
telemetry apply whichever model answered.
children = [
{Nx.Serving, serving: Jev.Nx.serving(Jev.Nx.Laya), name: MyApp.Laya, batch_size: 16}
]
config :jev, backend: Jev.Nx
config :jev_nx, serving: MyApp.Laya
Or per request, which is how a cascade asks the local model first and escalates to Jev when it is unsure:
def handle_call({:classify, text}, from, s),
do: {:reply, {{:local, from, text}, text, [kind: @kinds], [backend: Jev.Nx]}, s}
def handle_answer(%{confidence: %{kind: c}}, {:local, from, text}, s) when c < 0.7,
do: {:reply, {{:jev, from, text}, text, kind: @kinds}, s}
def handle_answer(%{kind: k}, {_stage, from, _text}, s), do: done(from, k, s)
Models
| Model | Weights | Backbone | Status |
|---|---|---|---|
| Laya | Apache 2.0, 421M | ModernBERT-large | Jev.Nx.Laya; sequences and probabilities verified against the reference |
Jev.Nx.Model is the contract for the next one: encode a question into a
marked token sequence, batch, run, and read a distribution per question out of
the outputs. Jev.Nx.Serving does the padding, batching, and compilation for
every model that implements it, and Jev.Nx.Defn implements the per-shape
step for a model that runs on Nx.Defn.
Runtimes
Laya runs two ways. runtime: :bumblebee, the default, builds the encoder as
an Axon graph and the head in Nx.Defn, so it goes wherever Nx goes.
runtime: :onnx loads the published export
into an ONNX Runtime session, for a deployment with no Nx compiler.
On an Apple M5, servings warm, medians from mix run bench/runtimes.exs
(Benchee, 10 seconds per runtime) and deviations from
mix run bench/accuracy.exs:
| Runtime | 1 question | 3 questions | largest difference from the reference |
|---|---|---|---|
:bumblebee + EMLX on Metal |
28.9 ms | 70.5 ms | 6.3e-3 |
:onnx on CPU |
107.3 ms | 326.2 ms | 4.0e-6 |
:bumblebee + EXLA on CPU |
214.0 ms | 628.0 ms | 3.0e-6 |
Metal is worth the jump, and its arithmetic is looser: probabilities move in the third decimal, enough to matter for a threshold sitting exactly on a boundary, not enough to change any label in the golden cases.
The ONNX runtime does not work with :onnxruntime 0.1.0. Laya's graph
declares a boolean marker_mask, Nx has no boolean type, and the binding maps
u8 to UINT8 only, so the session refuses the input. The fix belongs in the
binding, which knows the type the graph expects; until it lands, use the
default runtime.
Installation
def deps do
[
{:jev_nx, "~> 0.1"},
{:exla, "~> 0.13"} # or {:emily, "~> 1.0"} for Metal on Apple Silicon
]
end
config :nx, default_backend: EXLA.Backend
config :nx, :default_defn_options, compiler: EXLA
This package does not choose an Nx backend. Parameters load onto
Nx.default_backend/0, and the serving compiles with Nx.Defn.default_options/0
unless given defn_options. The checkpoint downloads from the Hub on first
load, and Jev.Nx.serving/3 loads it in the calling process, so in a child
spec the application waits for it; pass repository: {:local, dir} for a copy
on disk.
Shapes and batching
Questions from concurrent callers are batched by shape. Sequences pad to the
smallest bucket in sequence_length that fits, options to the smallest in
option_slots, and each pair of buckets is one compiled program, built on
first use or at start with compile: true:
Jev.Nx.serving(Jev.Nx.Laya, [checkpoint: :english],
sequence_length: [128, 256, 512],
option_slots: [4, 8, 16, 32, 64, 128],
batch_size: 16,
compile: true
)
Testing
mix test # without the checkpoint
mix test --include model # with it in ~/.cache/jev_nx/laya, or JEV_NX_LAYA_DIR
test/fixtures/golden.py records the reference implementation's sequences
and answers for the golden cases.