cerno

Typed decisions from a locally hosted model.

Ask yes/no, pick-one and rubric questions. cerno reads the answer from the model’s own probability distribution over a single token — nothing to parse, no number the model was asked to invent, and nothing leaves your machine.

Get started API docs GitHub

cernere, Latin: to sift, to distinguish, to decide.

noul

How likely the answer is yes.

0.0 .. 1.0

choice

Which one of up to 20 options.

the option, plus a probability for each

score

Where on a rubric of 2–10 levels.

the level, its legend, and a weighted mean

Three questions, one request

Request · POST /v1/systemone
curl localhost:3000/v1/systemone \
  -H 'content-type: application/json' -d '{
  "state": "Ticket: Server room cooling failed, 31 degrees and rising.",
  "questions": [
    {"id": "urgent", "noul": "Is this urgent?"},
    {"id": "team", "choice": {"question": "Which team?",
                              "options": ["IT", "Facility", "HR"]}},
    {"id": "sev",  "score":  {"question": "How severe?",
       "levels": ["negligible", "minor", "moderate", "major", "critical"]}}
  ]}'
Response, trimmed85 ms
{"answers": {
   "urgent": {"type": "noul",   "noul": 0.9946, "label_mass": 0.999},
   "team":   {"type": "choice", "choice": "Facility", "index": 1,
              "confidence": 0.967, "label_mass": 0.999},
   "sev":    {"type": "score",  "score": 5, "expected_score": 4.90,
              "legend": "critical", "confidence": 0.807,
              "label_mass": 0.999}},
 "model": "gemma4:e2b-it-qat",
 "usage": {"input_tokens": 281, "questions": 3},
 "timing_ms": {"total": 85}}

Read as

urgentnoul

0.9946

probability of yes

teamchoice

  • IT
  • Facility
  • HR

confidence 0.967

sevscore

critical 5 of 5

expected 4.90 · confidence 0.807

Three questions, 85 ms, on a 4 GB model. label_mass is how much of the model’s own probability fell on the offered letters — the one sign that it was about to write something else.

How it works

A generative model answers by writing words, and then something has to parse the words back into a decision. cerno never lets it get that far.

  1. Letter the options. Each question becomes a short prompt whose options are labelled A, B, C.
  2. Ask for one token. The model generates exactly one, in one forward pass, whatever the number of options.
  3. Read the distribution. Instead of taking the token, cerno reads P(A), P(B), P(C) from the logprobs.
  4. Fold, floor, calibrate. Tokenizer variants of a label are combined, a label that fell out of the window is bounded and flagged, and temperature scaling makes the confidences mean something.

That is where the speed comes from — one token, not a sentence — and where the probabilities come from, since they are the model’s own.

What the measurements forced

MeasuredConsequence
The first generated token was <|channel|>, a chat-template control tokenthink: false on every request
The default top_k: 40 truncates the distribution before logprobs are reported, and renormalises what survivestop_k: 0, top_p: 1, min_p: 0 are pinned and not configurable
“Nein” is not one token — it arrives as Ne + in — and “Ja” competes with JA,  Ja, jaSingle-letter labels only; variants of one label are folded in probability space
When a model is certain, the losing label drops out of the top-20 window entirelyThe weakest reported logprob becomes an upper bound, and the answer is flagged truncated
gemma4:26b answers clear cases at P = 1.0000Temperature scaling, per request or per model
A 26B model answers in 87 ms; a 4 GB one in 36 msA small model is the default

Get started

The service and the terminal front end are on crates.io; installing needs a Rust toolchain and nothing else.

Terminal
ollama pull gemma4:e2b-it-qat     # once, 4.3 GB
cargo install cerno-server cerno-tui

cerno-server                      # terminal 1: the service on 127.0.0.1:3000
cerno-tui                         # terminal 2: the terminal front end

The first request takes 5–10 s while Ollama loads the model; after that it is around 150 ms. http://localhost:3000/docs is the Swagger UI.

From code

Three clients, all published as cerno-sdk, written by hand against the same OpenAPI document and tested against the same conformance cases.

cargo add cerno-sdk
use cerno_sdk::Client;

let client = Client::new("http://localhost:3000")?;
let answers = client.systemone(text)
    .noul("urgent", "Is this urgent?")
    .choice("team", "Which team?", ["IT", "Facility"])
    .send().await?;
uv add cerno-sdk
from cerno import Client

client = Client("http://localhost:3000")
answers = (client.systemone(text)
    .noul("urgent", "Is this urgent?")
    .choice("team", "Which team?", ["IT", "Facility"])
    .send())
npm install cerno-sdk
import { Client } from "cerno-sdk";

const client = new Client("http://localhost:3000");
const answers = await client.systemone(text)
  .noul("urgent", "Is this urgent?")
  .choice("team", "Which team?", ["IT", "Facility"])
  .send();

The terminal front end

curl gives you the winner; cerno-tui shows you how close the runner-up was, which is the difference between “Facility” and “Facility, but it was nearly IT”. A run at a higher temperature next to one at 1.0 shows the calibration working directly.

cerno-tui with the server-room ticket as state, three questions, and the answers as bars: urgent 99.3% yes, team Facility at 99.5%, severity critical at 91.8%

Type a state, add questions with a, send with Ctrl+S. t and T step the calibration temperature, m cycles the models the service offers, ? lists the keys. The form is saved on exit and comes back on the next start.

Runs where your model runs

Every runtime gets the request fields that keep its distribution untruncated, and every host gets the same three primitives. Pick one with CERNO_HOST.

Which model to use is measured, not assumed: a 4 GB model matches the 26B reference’s accuracy and is better calibrated. See the model comparison.