cev-py
Typed, calibrated decisions from a small local model, in one forward pass.
cev for Python. You give it program
state and typed questions: is this true (noul), which of these
(choice), which level (score). It gives back typed answers with
probabilities. The model never generates text, so an answer outside the
options you listed is impossible, and a request takes tens of milliseconds.
It runs Qwen3 and Qwen3.5 checkpoints with cev's own zero-shot readout, and the open decision models natively (Bespoke Nimble, Jared Palmer's Kev, vLLM Semantic Router's d3, Liquid's d1 and d1-omni), including their image and audio inputs.
This package runs the cev engine inside your Python process as a native module: no server, no HTTP, no PyTorch. It uses the GPU on a Mac, Apple's BLAS or all the cores on a CPU, and releases the GIL while it works. Every answer is logged with an id, so you can send the right answer back later and the model learns from it while it runs.
from cev import Cev
cev = Cev.load("Qwen/Qwen3-1.7B")
res = cev.decide(
{"ticket": "The app crashes when I tap Log in"},
{
"team": {"type": "choice", "instructions": "Which team?", "criteria": {"billing": "Payments", "tech": "Bugs and crashes"}},
"refund": {"type": "noul", "instructions": "Is a refund requested?"},
"severity": {"type": "score", "instructions": "How severe?", "criteria": ["Cosmetic", "Degraded", "Blocking"]},
},
)
answers = res["answers"]
answers["team"]["choice"] # 'tech'
answers["team"]["probabilities"] # {'billing': 0.0, 'tech': 1.0}
answers["refund"]["noul"] # 0.007 probability of yes
answers["severity"]["score"] # 1.51 expected level: {'0': 0.015, '1': 0.459, '2': 0.525}
# Later, when you know the right answer:
cev.feedback(decision_id=answers["severity"]["x_cev"]["decision_id"], label=2)
Install
pip install cev-py # or: uv add cev-py
The distribution is called cev-py and the module cev. Wheels are built for
macOS on Apple silicon (with Metal and Accelerate) and for Linux x86_64 and
aarch64 (glibc 2.28 or later, CPU only); one wheel covers every Python from
3.11. Anywhere else pip builds from the source distribution, which needs a Rust
toolchain; for CUDA, see Building from source.
Weights are not bundled. Cev.load takes a Hugging Face repo id, a local
directory or a short name, and works out the model family from the files. It
looks in the Hugging Face cache first, then in ~/.cache/cev/models, and
downloads into the latter if the model is in neither (HF_TOKEN is
honoured). Adapters pull their pinned base model too.
model |
Download | Takes | Notes |
|---|---|---|---|
Qwen/Qwen3-0.6B |
1.5 GB | text | fastest; weaker on judgement calls (it scores the login crash above as cosmetic) |
Qwen/Qwen3-1.7B |
4.1 GB | text | the default |
Qwen/Qwen3-4B-Instruct-2507 |
text | wants a GPU | |
Qwen/Qwen3.5-* |
text, images | cev's zero-shot readout on the hybrid Qwen3.5 | |
kev:0.8b, kev:4b, kev (9B) |
text | Kev's LoRA and pointer head on Qwen3.5-Base, merged at load | |
nimble-v3 (9B) |
text | Bespoke Nimble's LoRA and schema prompt on Qwen3.5-9B | |
d3:2b … d3 (27B) |
text, images | d3's 255-way readout, attention run bidirectionally | |
d1 (3B) |
text, images | Liquid's LFM2.5-VL decision model | |
d1-omni (600M) |
2.3 GB | text, images, audio | Liquid's encoder with a decision head |
| a directory | any of the above, a LoRA fine-tune from rust-cev's training/train_lora.py, or an int8 Qwen3 copy from its quantize example (CPU only) |
cev.model["family"] and cev.model["modalities"] say what was loaded and
what it takes. rust-cev's Models
section has the details and the parity checks against PyTorch.
Questions and answers
A request is a state (free text, or anything json.dumps takes) and one or
more named questions. They are all answered in the same forward pass, each as
if it had been asked alone.
type |
Asks | criteria |
Answer |
|---|---|---|---|
noul |
is this true? | optional: what counts as yes, or {"true": ..., "false": ...} descriptions |
noul: probability of yes |
choice |
which of these? | {name: description}, or [name, ...] |
choice, confidence, probabilities by name |
score |
which level? | level descriptions, lowest first | score (expected level index), confidence, probabilities by index, legend |
Up to 255 options per question and 256 questions per request. Every answer
also has x_cev: its decision_id, its task, and whether an online adapter
adjusted it.
Requests and responses are plain dicts with the fields of cev's REST API
(POST /v1/systemone and friends). The package is typed (py.typed): the
shapes are TypedDicts such as Question, ChoiceAnswer and DecideResponse,
importable from cev.
Small models favour whichever option is listed first. cev corrects for that on
every request (debias):
debias |
Does | Cost |
|---|---|---|
none |
the raw distribution | |
calibrate |
subtracts what the model answers to the same question over empty evidence | free after the first request with a question |
permute |
averages over rotations of the option order (up to max_permutations) |
more tokens in the pass |
full |
both | about 2x calibrate |
auto (default) |
full for zero-shot code readouts (Qwen3, Qwen3.5, Nimble), none for the trained decision models (Kev, d3, d1, d1-omni) |
Images and sound
Models with image or audio in cev.model["modalities"] take pictures and
sound with the request. Every question sees them, before the state. Pass a
file path (pathlib.Path), the file's bytes, or a base64 string, with or
without a data: header.
from pathlib import Path
with Cev.load("d1-omni") as cev:
res = cev.decide(
"A customer sent this screenshot of the checkout page.",
{
"outcome": {"type": "choice", "instructions": "What does the screenshot show?",
"criteria": {"success": "The purchase went through", "failure": "Something went wrong"}},
"needs_help": {"type": "noul", "instructions": "Does the customer need help from support?"},
},
images=[Path("checkout.png")],
)
res["answers"]["outcome"]["probabilities"] # {'success': 0.033, 'failure': 0.967}
cev.decide(
"A voicemail left on the support line.",
{"callback": {"type": "noul", "instructions": "Does the caller want a call back?"}},
audio=Path("voicemail.wav"),
)
- Images: PNG, JPEG, WebP or GIF, for
d1,d1-omni,d3and Qwen3.5. Each family resizes and tiles them its own way. - Audio: one WAV clip, for
d1-omni. 16 kHz mono is best; other rates and stereo are resampled and mixed down. d1-omni takes images or audio in one request, not both. - A plain
stris always read as base64, never as a path. - A model that does not take what you sent raises
CevErrorwith codebad_request.
Feedback and online learning
Send the right answer back and cev stores it with the decision. With
online_learning (the default) it also updates a small per-task adapter on top
of the frozen model: a temperature, a bias per option and a linear probe on the
model's own hidden state.
res = cev.decide(state, {"team": {"type": "choice", "instructions": "Which team?", "criteria": criteria, "task": "router.team"}})
cev.feedback(decision_id=res["answers"]["team"]["x_cev"]["decision_id"], label="billing")
# or: cev.feedback(request_id=res["request_id"], question_id="team", label="billing", comment="...", metadata={"by": "agent-7"})
cev.tasks()
# [{'task': 'router.team', 'examples': 12, 'active': True, 'base_loss': 0.71, 'adapted_loss': 0.33, ...}]
- A task is one decision you make repeatedly. Give questions a
taskname; without one the key is a hash of the type, instructions and option names, so rewording a question starts a new adapter. - An adapter is served only once it has
min_exampleslabels and beats the base model on labels it had not yet seen. Until then, and whenever it stops winning, you get the base model.x_cev["adapted"]says which one answered. - Labels:
True/Falseor a probability fornoul; the option name forchoice; a level index forscore(fractions split between neighbours); or a full distribution{option: probability}for any of them. cev.examples([{"state": ..., "questions": ..., "labels": ...}])decides and labels in one call, for seeding a task with examples you already have.- A namespace keeps feedback apart. Adapters are kept per namespace and
task, so labels on decisions made with
namespace="acme"only change the answers served withnamespace="acme": one per customer, say. Requests without one share the default namespace.cev.tasks("acme")andcev.reset_task(task, "acme")work on one namespace;cev.tasks()lists them all.
The decision log
Pass db="cev.db" to keep decisions, feedback and adapters in a SQLite file
across restarts. The default is in memory. no_store=True on a request skips
the log (its answers then cannot take feedback).
cev.decisions(task="router.team", limit=20) # recent decisions
cev.decision(decision_id) # {'decision', 'state', 'prompt', 'feedback'} with the exact text the model saw
cev.stats() # {'store': {'requests', 'decisions', 'feedback', 'labeled_decisions'}, 'tasks', 'model'}
# Training rows for an offline LoRA fine-tune (rust-cev's training/train_lora.py --source labels.jsonl)
Path("labels.jsonl").write_text(cev.export_ndjson())
rows = cev.export(labeled=False) # the same, parsed; every decision, labelled or not
API
Cev.load(model=None, **options) |
load a model |
Cev.mock(**options) |
a keyword-overlap stand-in: no weights, instant, the whole API works. For tests |
cev.model |
{'id', 'backbone', 'family', 'modalities', 'hidden_size', 'max_options', 'prompt_version', 'threads', 'concurrency'} |
cev.decide(state, questions, *, images=None, audio=None, namespace=None, debias=None, no_store=False) |
answer the questions |
cev.feedback(*, decision_id=None, request_id=None, question_id=None, label=None, weight=None, comment=None, metadata=None) |
label or comment on an answer |
cev.examples(examples) |
decide, then learn from the labels; each example may carry images, audio and namespace |
cev.tasks(namespace=None) / cev.reset_task(task, namespace=None) |
adapter status / drop an adapter (its labels stay) |
cev.decisions(*, task=None, namespace=None, limit=50, offset=0) / cev.decision(decision_id) |
read the log |
cev.export(...) / cev.export_ndjson(...) |
training rows; filter by task, namespace, model, labeled, since, limit |
cev.stats() |
counts |
cev.close() |
free the model once the calls in flight have returned; with Cev.load(...) as cev: does it for you |
info() (from cev import info) |
what the module was built with: {'version', 'metal', 'accelerate', 'cuda', 'cores', 'performance_cores', 'threads'} |
Options
| Option | Default | |
|---|---|---|
model |
Qwen/Qwen3-1.7B |
Hugging Face repo id, a directory, or a short name (kev:0.8b, d1-omni, ...); see Install |
device |
auto |
auto (Metal, then CUDA, then CPU), cpu, metal, cuda |
dtype |
auto |
f32 on CPU and for checkpoints up to 4 GB on Metal, else bf16; or f16 |
threads |
performance cores | CPU threads for the tensor kernels; see Performance |
concurrency |
1 |
forward passes that may run at once |
db |
:memory: |
SQLite file for the log |
debias |
auto |
default for requests that do not set it |
max_permutations |
4 |
most option rotations per question |
online_learning |
True |
when false, labels are stored but nothing adapts |
min_examples |
8 |
labels before an adapter may be served |
store_features |
True |
keep hidden states with decisions; learning from later feedback needs them |
max_context |
32768 |
longest prompt in tokens (capped by the model) |
prefix_cache_tokens |
65536 |
tokens of state prefixes kept for reuse |
name |
cev-<model> |
the model reported in responses |
Cev.mock takes the options from db to store_features, and media=True
to accept images and audio (it is text-only otherwise, like Qwen3).
Errors
Every failure is a cev.CevError with a code:
code |
Means |
|---|---|
bad_request |
the call was malformed: no questions, one option, a label that is not an option, images or audio the model does not take |
not_found |
no such decision (was it made with no_store?) |
closed |
the instance was closed |
internal |
everything else: missing weights, a failed download, I/O |
Set CEV_LOG=debug to print the engine's log, including the time each pass
spent tokenizing, prefilling and answering, to stderr.
Threads and asyncio
Every method releases the GIL while it works, so you can call one Cev from
many threads. Forward passes take turns (concurrency at a time); feedback and
log reads do not wait for them.
with ThreadPoolExecutor(8) as pool:
results = list(pool.map(lambda ticket: cev.decide(ticket, questions), tickets))
# asyncio: the call blocks its thread, not the event loop
res = await asyncio.to_thread(cev.decide, state, questions)
Performance
Qwen3-0.6B, f32, on an Apple M4 Max (12 performance and 4 efficiency cores).
The request is the one rust-cev benchmarks with: a short ticket and 3 questions
(270 tokens with calibrate, 570 with full). "New state" is a state the
model has not seen; "same state" repeats it, so its prefix comes from the
cache. Median latency of one request at a time, in milliseconds:
calibrate: new / same state |
full: new / same state |
3k-token state, calibrate: new / same |
|
|---|---|---|---|
| CPU | 183 / 131 | 394 / 335 | 3425 / 359 |
| Metal | 46 / 35 | 88 / 77 | 603 / 68 |
Qwen3-1.7B on Metal: 97 / 69 and 182 / 158. The very first request with a
question costs about twice a new state (its calibration is computed once and
cached). Loading takes about half a second once the weights are in the page
cache. Reproduce with
uv run python scripts/bench.py --model Qwen/Qwen3-0.6B --device cpu.
What the cores are doing
- On a Mac, use Metal (
device="auto"does). It is 4 to 6 times faster than the CPU on the same machine. - On the CPU, a pass runs on a pool of
threadsthreads: the matmuls through Apple's Accelerate on macOS and through multi-threaded portable kernels elsewhere, the element-wise work (softmax, norms, rotary embedding) split over the pool. The default is the performance cores; efficiency cores slow a pass down because work is split evenly and everyone waits for the slowest.RAYON_NUM_THREADSorthreadsoverrides it. The pool belongs to the process, so only the firstCev.loadsets its size. - More threads stop helping a single request early on Apple silicon. About 70% of a CPU pass is matmul that Accelerate already runs at the chip's limit (about 1.9 TFLOP/s on the M4 Max), and that part does not scale with threads. With Qwen3-0.6B a short request with a new state takes 254 ms on 1 thread, 188 on 4, 180 on 8 and 183 on 12.
- For throughput on the CPU, raise
concurrencyand call from several threads. Several passes side by side use what one pass leaves idle: with 8 threads calling, Qwen3-0.6B goes from 5.4 to 10.6 requests/s atconcurrency=8. Each request gets slower, so leave it at 1 if latency matters more. On a GPU the passes queue on the device anyway (21 requests/s on Metal): keep it at 1. - Ask your questions together. One request with 12 questions is cheaper than 4 requests with 3 each, and the state is read once.
- Keep the state prefix stable. A request whose state was seen before skips straight to the questions. The cache matches on the rendered state, so put what changes last.
calibrateis half the cost offullif you can live without the rotation averaging.
Building from source
uv sync # builds the module (release) into .venv, with pytest
uv run pytest # mock backend; no weights needed
CEV_TEST_MODEL=Qwen/Qwen3-0.6B uv run pytest # also run a real model
uv run maturin develop --release # rebuild after changing src/lib.rs
uv run maturin develop --release --features cuda # NVIDIA GPUs (needs the CUDA toolkit)
uv run python scripts/bench.py --model Qwen/Qwen3-0.6B --device cpu
To build against an engine checkout instead of crates.io (with rust-cev next to this repository):
uv run maturin develop --release \
--config 'patch.crates-io.cev-core.path="../rust-cev/crates/cev-core"' \
--config 'patch.crates-io.cev-model.path="../rust-cev/crates/cev-model"' \
--config 'patch.crates-io.cev-runtime.path="../rust-cev/crates/cev-runtime"'
src/lib.rsis a small Rust crate (PyO3) that links the cev crates from crates.io (Cargo.toml);python/cev/is the Python API on top of it.- The module uses the stable ABI (
abi3), so one build serves Python 3.11 and later. - Only macOS on Apple silicon is tested so far. The same engine builds and passes its mock-backend tests on Linux x64 through node-cev's CI; this package has not been built there yet, and Windows is untried.
How this relates to rust-cev
rust-cev is the project: the prompt
compiler, the engines for Qwen3, Qwen3.5 and the decision models, the decision log, the online learner, an HTTP and
GraphQL server, a browser build and a typed Rust SDK. This repository is only
the Python binding (about 300 lines of Rust glue and a typed wrapper), and
none of the engine is copied here. The same binding for Node.js is
node-cev. If you would rather run a
server and call it over HTTP, use cev-server from rust-cev; its
/v1/systemone takes the same request this package's decide builds.
License
Apache-2.0, like cev.
Metadata
Release files for cev-py 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| cev_py-0.2.0.tar.gz | 62.5 kB | With provenance | Details for cev_py-0.2.0.tar.gz |
Built distributions (wheels)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| cev_py-0.2.0-cp311-abi3-manylinux_2_28_x86_64.whl | CPython 3.11 | abi3 | Linux glibc 2.28+ x86-64 | Details for cev_py-0.2.0-cp311-abi3-manylinux_2_28_x86_64.whl |
| cev_py-0.2.0-cp311-abi3-manylinux_2_28_aarch64.whl | CPython 3.11 | abi3 | Linux glibc 2.28+ ARM64 | Details for cev_py-0.2.0-cp311-abi3-manylinux_2_28_aarch64.whl |
| cev_py-0.2.0-cp311-abi3-macosx_11_0_arm64.whl | CPython 3.11 | abi3 | macOS 11.0+ ARM64 | Details for cev_py-0.2.0-cp311-abi3-macosx_11_0_arm64.whl |
Total release size: 23.0 MB
Release files / cev_py-0.2.0.tar.gz
| Download URL | cev_py-0.2.0.tar.gz |
|---|---|
| Size | 62.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3979c0c8a678b3942d75e63a193bbeeb3ef0ed5e0e6af5185e329c9890263209
|
|
BLAKE2b-256 checksum How to use checksums |
a7ffd4dea5f9216d19872be33f3e7e62b46f1b1e09b766cdd5efbb895af59b23
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logRelease files / cev_py-0.2.0-cp311-abi3-manylinux_2_28_x86_64.whl
| Download URL | cev_py-0.2.0-cp311-abi3-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 8.5 MB |
| Tags | CPython 3.11 Linux glibc 2.28+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
a53528b07c16a01c7b78305f98f10c5584e46fa5f1dc33821a4a9b0eb2a4fc6c
|
|
BLAKE2b-256 checksum How to use checksums |
c42a648b4468a91129c9e99081642912528c05c331f4f0e18dd7732bb3d24e13
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logRelease files / cev_py-0.2.0-cp311-abi3-manylinux_2_28_aarch64.whl
| Download URL | cev_py-0.2.0-cp311-abi3-manylinux_2_28_aarch64.whl |
|---|---|
| Size | 8.0 MB |
| Tags | CPython 3.11 Linux glibc 2.28+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
0695e2a7116d5c3cbe519fbc22216420af872dc8e66ce57aadcee8ecbff7f63e
|
|
BLAKE2b-256 checksum How to use checksums |
2b6530c2ae44ffb6e5b2fb3c0b3bea6f00d73e20e9036e2c84fa89c834b94b46
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logRelease files / cev_py-0.2.0-cp311-abi3-macosx_11_0_arm64.whl
| Download URL | cev_py-0.2.0-cp311-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 6.4 MB |
| Tags | CPython 3.11 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
b69f9650efdbd7f5263b0589c5d48f86ff4eadf6c3df108639d821f73e0f3605
|
|
BLAKE2b-256 checksum How to use checksums |
8cec7f0668ac79c8f63b1499f0ed3e33fc5cd6a34abfdd9d9f94ef543b1b235f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logSecurity for cev-py 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 4 files in this release have been uploaded with provenance metadata.
Attestation for cev_py-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.