Technical Report · Independent Project

JEV-CPU: Running Semantic-If Decisions on a CPU

leesk212 · Meanblock
September 19, 2026 · v1

Abstract. Semantic-if decisions — route this, is this a policy violation, what severity is this incident — are usually answered by having a chat model generate text that software then parses back into a branch. SemIf (an open reproduction of TypeSafe's Jev pattern) instead reads a decision directly from a model's option logits in a single forward pass, with no text generated. SemIf targets a GPU holding a 4B model. This report describes JEV-CPU, a small adaptation that runs the same engine on a commodity CPU with a 0.6B open model, and reports measurements from an 8 GB, GPU-less machine: qualitative decisions across eight domains, a latency-versus-input-length curve, and the resulting practical input ceiling. We make no claim of methodological novelty — the contribution is engineering and empirical: showing the method is device-agnostic, quantifying where CPU latency (not memory or the model) becomes the limit, and documenting how accuracy scales with model size. All code, weights pointer, and demos are public.

Contents

JEV-CPU deciding live across eight domains on CPU
Figure 1. JEV-CPU running live on a CPU across eight domains: the state is typed in, criteria are added, and each decision is read from Qwen3-0.6B's option logits in ~1 s — no text generated.

1. Introduction

Most decisions inside software agents are small and typed: choose a queue, pick a severity, decide whether evidence supports a claim. A chat model can answer them, but generating an answer sentence and parsing it back into an if is slow and brittle. A decision-native alternative is to present the options as tokens and read the model's probability over exactly those tokens in one forward pass. TypeSafe's closed Jev service popularized this interface; SemIf reproduces the interface pattern with open models on a GPU.

This report asks a narrow, practical question: does the method still work with no GPU and a tiny model, and where does it break down? We port SemIf's scoring to CPU (§4), run it across eight domains (§5), and measure the latency wall that determines the usable input size (§6).

2. Background

Reading a categorical decision from an LM's next-token distribution — rather than sampling text — is a well-established idea (verbalizer-style zero-shot classification, answer-token scoring, NLI cross-encoders). We claim no novelty for the mechanism. JEV-CPU is a CPU adaptation and empirical study of SemIf, which is itself an independent reproduction of the Jev pattern; Jev and TypeSafe are the property of their owners and are not affiliated with this work.

3. Method: reading a decision from logits

A decision is one record: a state (evidence), a question (criterion), and 2–16 typed options. The direct scoring path is unchanged from SemIf:

  1. Letter-choice prompt. Each option is labeled A, B, C… in a chat turn whose system instruction asks for a single uppercase letter and nothing else. With the generation prompt applied, the next token the model would emit is the answer letter.
  2. Single-token pinning. Each letter is verified to encode to exactly one token that round-trips and does not perturb the prompt's tokenization, so every option maps to one clean, comparable vocabulary slot.
  3. One forward pass. The prompt is run through the model once; we keep the logits at the final position — the distribution over the next token. No sampling, no decode loop, no JSON to repair.
  4. Softmax over slots. From that full-vocabulary logit vector we gather only the option letters' token ids and softmax over those, giving a probability per option conditional on the declared set.
logits   = model(**inputs, use_cache=False).logits[:, -1, :]   # (vocab,)
selected = logits[slot_ids]        # logits at tokens A, B, C, ...
probs    = softmax(selected)       # distribution over the declared options
winner   = options[argmax(probs)]

Because it is one forward pass reading fixed positions, latency is dominated by prompt prefill, not by generation length — the property JEV-CPU relies on to stay usable on a CPU.

4. System: the CPU port

SemIf forces a GPU in exactly one place — its model loader checks for a single CUDA device and loads with device_map={"":"cuda:0"}, bfloat16. Everything downstream is device-agnostic: the scoring code follows device = next(model.parameters()).device and the only CUDA-specific call, torch.cuda.synchronize(), is guarded by if device.type == "cuda" (a no-op on CPU).

JEV-CPU therefore changes only the loader: a CPU / float32 loader that reuses SemIf's original, unmodified scoring functions. A small standard-library web server exposes the engine with a three-pane UI (state, criteria, results). Swapping the model is a one-line change (MODEL = …); the engine and UI are model-agnostic.

JEV-CPU three-pane web UI
Figure 2. The web UI: state (top-left), criteria (bottom-left), and per-option probability bars with the chosen option (right). The footer names the method (JEV-CPU engine) and the brain model.

5. Qualitative results across domains

Using Qwen/Qwen3-0.6B in float32 on CPU, we ran the same engine across eight domains (Figure 1). Each decision took ≈1 s.

DomainCriterionDecisionForward
Customer supportSentimentnegative — 97.4%~1.1 s
Customer supportRoute to teambilling — 100%~1.0 s
Content moderationPolicy violation?violation — 99.3%~1.1 s
Content moderationRecommended actionwarn — 69.2%~1.0 s
Code-review triageMerge riskhigh — 99.8%~1.2 s
Code-review triagePR dispositionblock — 94.9%~1.1 s
Incident / DevOpsSeveritysev1 — 100%~1.2 s
Incident / DevOpsPage on-call now?page_now — 100%~1.1 s
Email intentPrimary intentsales — 100%~1.2 s
Compliance gateChange ticket required?required — 100%~1.1 s
Loan / credit riskCredit riskhigh — 100%~1.2 s
Loan / credit riskRecommended decisionapprove — 82.8% ⚠︎~1.1 s
Support prioritizationPriorityp1 — 100%~1.2 s

These are single illustrative runs, not a benchmark; they show the interface and the qualitative behavior. The loan decision row is a deliberate example of a small-model slip (see §8).

6. Latency and input-length limits

Two numbers are often conflated. The model's context window is 40,960 tokens — large even at 0.6B, since context length comes from positional encoding, independent of parameter count. SemIf's engine applies a default cap of 4,096 tokens per decision as a guard (over-long prompts raise instead of being silently truncated); it is configurable. Neither is the real constraint on CPU.

We measured one decision with a growing state on the 8 GB CPU box (peak RSS via getrusage):

Input tokensForward (CPU)Peak RAM
2542.5 s~3.5 GB
7315.6 s~3.5 GB
1,36311.9 s~3.5 GB
2,62926.0 s~3.5 GB
5,16566.1 s~3.5 GB
7,697117.4 s~3.5 GB

RAM stayed flat at ~3.5 GB with no OOM even at 7,697 tokens — well past the 4,096 default — so the ceiling on this box is prefill latency (roughly quadratic in length), not memory or the model.

Practical input ceiling. We treat ≈7,700 tokens (~117 s) as the usable maximum on this CPU: beyond it a single decision crosses ~120 s, which is no longer useful, so larger inputs are considered unsupported on this hardware. Interactive ~1 s decisions want short states (≲~300 tokens). A GPU removes this wall — inputs up to the model's 40,960 become usable again.

7. Accuracy scaling with model size

Qwen3-0.6B is the smallest model on SemIf's ladder, chosen to fit in CPU RAM; it is the accuracy floor, not the ceiling. From SemIf's own evaluation:

Brain modelSizeAuthored balanced acc.TypeSafe subset agr.
Qwen3-0.6B (JEV-CPU default)0.6 B0.4400.407
MiniCPM5-2B2 B0.6860.637
Qwen3.5-4B4 B0.8130.845

Because the engine is model-agnostic, moving up the ladder is a one-line change — at the cost of RAM and compute that exceed this CPU box (a 4B model wants a GPU, SemIf's target). The takeaway: JEV-CPU shows the method runs anywhere; accuracy scales with the model you point it at.

7.1 Outlook — GPU serving and a production JEV

The two axes of this report — accuracy (§7) and CPU latency (§6) — are usually assumed to trade off, but a GPU relaxes both at once. On CPU the wall is prefill: a single 7,697-token decision took ~117 s (§6). A GPU changes the regime on three fronts:

Put together, a larger open-weight model (4B+) served on a GPU is simultaneously more accurate, accepts far larger inputs, and answers many decisions per second — all with typed, auditable outputs and no generation to parse. That combination is the shape of a production, potentially commercial, JEV: semantic-if offered as a low-latency, high-throughput hosted service, with per-tenant models and batched shared-state decisions. In that framing, JEV-CPU is the floor — proof the method is portable to any machine — and a GPU-served larger model is the ceiling that turns the same engine into a product.

Scope. We did not run GPU experiments in this report. The latency and throughput figures above are SemIf's published single-GPU (RTX 3090, 4B) measurements together with projections from our own CPU curve (§6) — offered as an implication, not as measured results of this work. Quantifying a GPU-served JEV (tokens/s, decisions/s, cost per million decisions, batching and concurrency) is the natural next study.

8. Limitations

9. Conclusion

Decision-native, logit-readout inference is not tied to a GPU or a large model. With only a loader change, SemIf's engine runs on a commodity CPU with a 0.6B model, decides across many domains at ~1 s each, and stays memory-stable well past its default token cap. On this hardware the honest limit is latency: inputs above ~7,700 tokens cross the ~120 s mark and are impractical, and small-model accuracy — not context or memory — is what improves by scaling the model up. JEV-CPU is offered as a reproducible, minimal demonstration of that floor; the same engine, given a larger open-weight model on a GPU, points toward the ceiling (§7.1) — an accurate, high-throughput, low-latency semantic-if service, i.e. a production JEV.

10. Reproducibility

Code, the web UI, the exact scripts behind §5–§6, and all demo GIFs are public:

python3 -m venv .venv && source .venv/bin/activate
pip install --index-url https://download.pytorch.org/whl/cpu torch
pip install transformers accelerate
python semif_cpu.py      # CLI: typed option probabilities
python server.py         # web UI on http://localhost:8080

Appendix A. Code-level walkthrough: turning an open-weight model into a decision engine

A "JEV" / decision engine is not a fine-tune and not new weights — it is a way of calling an ordinary open-weight causal language model so that a typed decision falls out of a single forward pass. This appendix walks the full pipeline function by function, as it runs in JEV-CPU. The scoring code is SemIf's, reproduced here verbatim for the report; JEV-CPU changes only the model loader (A.7). All snippets are from src/semif_phase1/{core,direct}.py.

A.1 — The record and its validation

A decision is one plain record: a state (evidence, string / JSON object / array), a question (criterion), and 2–16 options, each with a stable id and a human description. Validation is strict so nothing is silently coerced:

def validate_row(row):
    required = {"id", "state", "question", "options"}
    if not required <= row.keys(): raise ValueError(...)
    # state must be a nonempty, finite, JSON-serializable string/object/array
    json.dumps(state, ensure_ascii=False, allow_nan=False)
    # 2..16 options, each {id: str, description: str}, ids unique
    if not isinstance(options, list) or not 2 <= len(options) <= len(LETTERS): raise ValueError(...)

A.2 — Building a letter-choice prompt

Each option is assigned an uppercase letter and the record is serialized into a two-turn chat. The system turn constrains the model to answer with a single letter and nothing else — this is what makes the next token the entire decision:

LETTERS = "ABCDEFGHIJKLMNOP"
DIRECT_SYSTEM = ("Apply the supplied criterion to the supplied evidence. "
                 "Choose exactly one listed option. Respond with only its "
                 "uppercase letter, with no explanation or reasoning.")

def direct_messages(row):
    payload = {
        "evidence":  row["state"],
        "criterion": row["question"],
        "options":   [{"letter": LETTERS[i], "description": o["description"]}
                      for i, o in enumerate(row["options"])],
    }
    return [{"role": "system", "content": DIRECT_SYSTEM},
            {"role": "user",   "content": json.dumps(payload, ensure_ascii=False)}]

A.3 — Pinning each option to exactly one token

The readout compares the model's probability of each answer letter, so every letter must map to a single, clean vocabulary slot. Each letter is checked to encode to exactly one token that round-trips, with no collisions between options:

def _slot_ids(tokenizer, count):
    result = []
    for letter in LETTERS[:count]:
        encoded = tokenizer.encode(letter, add_special_tokens=False)
        if len(encoded) != 1 or tokenizer.decode(encoded) != letter:
            raise ValueError(f"Answer slot {letter!r} is not one exact round-trip token")
        result.append(encoded[0])
    if len(result) != len(set(result)):
        raise ValueError("Answer-slot tokens collide")
    return result            # e.g. token ids for "A", "B", "C"

A.4 — Encoding and verifying the answer boundary

The chat template is applied with the generation prompt on and thinking disabled, then two invariants are asserted: the prompt fits the token budget (no silent truncation), and appending any answer letter extends the tokenization by exactly that one slot token — i.e. the boundary between prompt and answer is stable:

def encode_prompt(tokenizer, row, max_tokens):
    prompt = tokenizer.apply_chat_template(
        direct_messages(row), tokenize=False,
        add_generation_prompt=True, enable_thinking=False)
    ids = tokenizer.encode(prompt, add_special_tokens=False)
    if not ids or len(ids) > max_tokens:
        raise ValueError("input tokens exceed limit; no truncation allowed")
    slots = _slot_ids(tokenizer, len(row["options"]))
    for letter, token in zip(LETTERS, slots):
        if tokenizer.encode(prompt + letter, add_special_tokens=False) != ids + [token]:
            raise ValueError(f"Answer boundary changes tokenization for slot {letter}")
    return ids, slots, digest(prompt)   # digest = sha256 for auditability

A.5 — One forward pass, last-position logits

The whole model is run once. We keep only the final position's logits — the distribution over the next token. logits_to_keep=1 (when the model supports it) tells the model to compute just that row, avoiding a full-sequence logit tensor. There is no sampling and no decode loop:

def _forward(model, inputs):
    params = inspect.signature(model.forward).parameters
    kwargs = dict(inputs, use_cache=False, return_dict=True)
    if "logits_to_keep" in params:
        kwargs["logits_to_keep"] = 1
    return model(**kwargs).logits[:, -1, :]     # shape (batch, vocab)

A.6 — Gather the slots, softmax, decide

From the full-vocabulary logit vector we index the option-letter tokens and softmax over only those, giving a probability per option conditional on the declared set. Note the two device lines: the code reads whatever device the model is on and only synchronizes under CUDA — the property that makes it run unchanged on CPU:

def score(model, tokenizer, row, metadata, max_tokens=4096):
    import torch
    ids, slots, prompt_hash = encode_prompt(tokenizer, row, max_tokens)
    device = next(model.parameters()).device          # <- follow the model
    inputs = {"input_ids":      torch.tensor([ids], dtype=torch.long, device=device),
              "attention_mask": torch.ones((1, len(ids)), dtype=torch.long, device=device)}
    if device.type == "cuda": torch.cuda.synchronize(device)   # <- no-op on CPU
    with torch.inference_mode():
        vocabulary = _forward(model, inputs)[0].float()        # (vocab,)
    selected = vocabulary[slots].cpu().tolist()   # logits at A, B, C, ...
    return {
        "option_ids":   [o["id"] for o in row["options"]],
        "option_logits": selected,
        "probabilities": softmax(selected),        # winner = argmax
        "input_tokens":  len(ids),
        "readout": "native full-vocabulary last-position logits restricted to declared answer slots",
        "probability_status": "conditional option score; uncalibrated as decision confidence",
        ...
    }

That is the entire "JEV-ification": a stock AutoModelForCausalLM is never asked to generate; it is asked once for its next-token logits, and the decision is the arg-max over the option slots.

A.7 — The only change to run on CPU

SemIf's loader hard-codes a single CUDA device and bfloat16. JEV-CPU replaces just this function; every function above is untouched:

SemIf — core.load_causal_model (GPU)JEV-CPU — load_causal_model_cpu
if not torch.cuda.is_available() \
   or torch.cuda.device_count() != 1:
    raise ValueError("Expose exactly "
        "one CUDA GPU ...")
...
model = cls.from_pretrained(
    source, config=config,
    dtype=torch.bfloat16,
    device_map={"": "cuda:0"},
    low_cpu_mem_usage=True, **common)
# no CUDA check, no device_map
model = AutoModelForCausalLM.from_pretrained(
    source, config=config,
    dtype=torch.float32,   # CPU-stable
    low_cpu_mem_usage=True, **common)
model.eval()

Because score() and score_shared() derive their device from the model object, loading on CPU is sufficient — the same scoring code then runs with torch.cuda.synchronize skipped. Swapping the brain model is the one other knob: change MODEL = "Qwen/Qwen3-0.6B" to any causal LM whose answer letters are single tokens.

A.8 — Reusing one state across many criteria (shared mode)

When many criteria judge the same state, shared.py prefills that state once into a native key–value cache, replicates the cache across branches with cache.reorder_cache(...), and evaluates every criterion's answer position in one batched forward using a vector logits_to_keep. One expensive prefill, many cheap decisions — and, like the direct path, it synchronizes only under CUDA, so it too runs unchanged on CPU.


References

  1. T. Lee (TheoLeeCJ). SemIf — Semantic ifs from open models. github.com/TheoLeeCJ/SemIf. Browser demo: openjev.com.
  2. TypeSafe. Jev (closed service for runtime-defined semantic decisions). Names/marks are the property of their owners; this work is independent and unaffiliated.
  3. Qwen Team. Qwen3-0.6B. huggingface.co/Qwen/Qwen3-0.6B.
  4. A. Liu et al. WANLI: Worker and AI Collaboration for NLI. dataset (used in SemIf's evaluation cited in §7).
  5. This report and JEV-CPU code are released under the MIT License.