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. It gives a full code-level account of the mechanism (§5) 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, documenting exactly how an open-weight model becomes a decision engine in code, and quantifying where CPU latency (not memory or the model) becomes the limit.
Qwen3-0.6B's option logits in ~1 s — no text generated.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?
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.
A decision is one record: a state (evidence), a question (criterion), and
2–16 typed options. The direct scoring path (unchanged from SemIf) is:
A, B,
C… ; the system turn asks for a single uppercase letter, so the next token is the answer.Latency is dominated by prompt prefill, not generation length — the property JEV-CPU relies on to stay usable on a CPU. The concrete code is dissected in §5.
SemIf forces a GPU in exactly one place — its 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 (CPU / float32) and reuses SemIf's
original scoring. A standard-library web server exposes it with a three-pane UI.
This is the empirical core of the report. A "JEV" / decision engine is not a fine-tune and not new
weights — it is a specific way of calling an ordinary open-weight causal LM so that a typed
decision falls out of a single forward pass. Below is the whole pipeline, function by function, as it runs in
JEV-CPU. The scoring code is SemIf's, reproduced verbatim;
JEV-CPU changes only the loader (§5.9). Snippets are from
src/semif_phase1/{core,direct}.py.
One decision travels through eight steps; no step generates text:
record {state, question, options}
→ validate_row (5.2 strict schema check)
→ direct_messages (5.3 option → letter, chat turns)
→ encode_prompt (5.5 chat template → token ids)
→ _slot_ids (5.4 each letter is one clean token)
→ boundary check (5.5 answer boundary is stable)
→ _forward (5.6 ONE forward, last-position logits)
→ gather slots + softmax (5.8 probability per option → arg-max)
⇒ decision, in ~1 forward pass
The input is one plain record. Validation is strict, so nothing is silently coerced or truncated later:
def validate_row(row):
required = {"id", "state", "question", "options"}
if not required <= row.keys(): raise ValueError(...)
json.dumps(state, ensure_ascii=False, allow_nan=False) # finite JSON
if not isinstance(options, list) or not 2 <= len(options) <= len(LETTERS): raise ValueError(...)
state may be a nonempty string / JSON object / array; there must be 2–16 options,
each {id, description}, with unique ids.
Each option is assigned an uppercase letter; the record becomes a two-turn chat whose system turn forbids anything but a single letter. This is what makes the next token carry the whole 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)}]
The readout compares the model's probability of each answer letter, so each letter must be a single, clean vocabulary slot — one round-tripping token, no collisions:
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 # token ids for A, B, C, ...
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 letter extends the tokenization by exactly that one slot token — the prompt/answer boundary 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("exceeds limit; no truncation")
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) # sha256 for auditability
The whole model runs once; we keep only the final position — the distribution over the next
token. logits_to_keep=1 avoids materializing a full-sequence logit tensor. No sampling, no 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, :] # (batch, vocab); no sampling
The scoring function reads whatever device the model is on and only synchronizes under CUDA. These two lines are the entire reason the same code runs on a GPU or a CPU unchanged:
device = next(model.parameters()).device # follow the model
if device.type == "cuda": torch.cuda.synchronize(device) # no-op on CPU
From the full-vocabulary logit vector we index only the option-letter tokens and softmax over those, producing a probability per option (conditional on the declared set); the winner is the arg-max:
def score(model, tokenizer, row, metadata, max_tokens=4096):
ids, slots, prompt_hash = encode_prompt(tokenizer, row, max_tokens)
device = next(model.parameters()).device
inputs = {"input_ids": torch.tensor([ids], device=device),
"attention_mask": torch.ones((1, len(ids)), device=device)}
if device.type == "cuda": torch.cuda.synchronize(device)
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), ...}
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.
SemIf — load_causal_model (GPU) | JEV-CPU — load_causal_model_cpu |
|---|---|
|
|
Because §5.7–§5.8 derive the device from the model, loading on CPU is sufficient; the same scoring code runs
with the CUDA sync skipped. Swapping the brain is the one other knob: set MODEL to any causal LM
whose answer letters are single tokens.
When many criteria judge the same state, shared.py prefills it once into a KV cache,
replicates the cache with cache.reorder_cache(...), and evaluates every criterion's answer position
in one batched forward via a vector logits_to_keep — one prefill, many decisions; also CUDA-synced
only under CUDA. This is the path that makes high-throughput serving cheap (§8.1).
Using Qwen/Qwen3-0.6B in float32 on CPU, the same engine ran across eight
domains (Figure 1); each decision took ≈1 s.
| Domain | Criterion | Decision | Forward |
|---|---|---|---|
| Customer support | Sentiment | negative — 97.4% | ~1.1 s |
| Customer support | Route to team | billing — 100% | ~1.0 s |
| Content moderation | Policy violation? | violation — 99.3% | ~1.1 s |
| Content moderation | Recommended action | warn — 69.2% | ~1.0 s |
| Code-review triage | Merge risk | high — 99.8% | ~1.2 s |
| Code-review triage | PR disposition | block — 94.9% | ~1.1 s |
| Incident / DevOps | Severity | sev1 — 100% | ~1.2 s |
| Incident / DevOps | Page on-call now? | page_now — 100% | ~1.1 s |
| Email intent | Primary intent | sales — 100% | ~1.2 s |
| Compliance gate | Change ticket required? | required — 100% | ~1.1 s |
| Loan / credit risk | Credit risk | high — 100% | ~1.2 s |
| Loan / credit risk | Recommended decision | approve — 82.8% ⚠︎ | ~1.1 s |
| Support prioritization | Priority | p1 — 100% | ~1.2 s |
Single illustrative runs, not a benchmark. The loan decision row is a deliberate example of a small-model slip (see §9).
Two numbers are often conflated. The model's context window is 40,960 tokens — large even at 0.6B. SemIf's engine caps a decision at 4,096 tokens by default (a guard; configurable). Neither is the real constraint on CPU. Measured on the 8 GB CPU box, one decision with growing input:
| Input tokens | Forward (CPU) | Peak RAM |
|---|---|---|
| 254 | 2.5 s | ~3.5 GB |
| 731 | 5.6 s | ~3.5 GB |
| 1,363 | 11.9 s | ~3.5 GB |
| 2,629 | 26.0 s | ~3.5 GB |
| 5,165 | 66.1 s | ~3.5 GB |
| 7,697 | 117.4 s | ~3.5 GB |
RAM stayed flat at ~3.5 GB with no OOM even at 7,697 tokens — the ceiling here is prefill latency (≈ quadratic), not memory or the model.
Qwen3-0.6B is the smallest model on SemIf's ladder, the accuracy floor. From SemIf's evaluation:
| Brain model | Size | Authored balanced acc. | TypeSafe subset agr. |
|---|---|---|---|
| Qwen3-0.6B (JEV-CPU default) | 0.6 B | 0.440 | 0.407 |
| MiniCPM5-2B | 2 B | 0.686 | 0.637 |
| Qwen3.5-4B | 4 B | 0.813 | 0.845 |
The engine is model-agnostic, so moving up is a one-line change (MODEL = …), at the cost of
RAM/compute beyond this box. The method runs anywhere; accuracy scales with the model you point it at.
Accuracy (§8) and CPU latency (§7) are usually assumed to trade off, but a GPU relaxes both at once:
Together, a larger open-weight model (4B+) on a GPU is simultaneously more accurate, accepts far larger inputs, and answers many decisions per second — with typed, auditable outputs and nothing to parse. That is the shape of a production, potentially commercial, JEV: semantic-if as a low-latency, high-throughput hosted service. JEV-CPU is the floor (portable to any machine); a GPU-served larger model is the ceiling that turns the same engine into a product.
Decision-native, logit-readout inference is not tied to a GPU or a large model. As §5 shows in code, an ordinary open-weight model becomes a decision engine with a loader change alone; SemIf's engine then 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 small-model accuracy — not context or memory — is what improves by scaling up. JEV-CPU is the reproducible floor; the same engine, given a larger open-weight model on a GPU, points toward the ceiling (§8.1) — an accurate, high-throughput, low-latency semantic-if service, i.e. a production JEV.
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
초록. Semantic-if 결정 — 이걸 어디로 보낼까, 정책 위반인가, 이 인시던트의 심각도는 — 은 보통 챗 모델이 텍스트를 생성하고 소프트웨어가 그 텍스트를 다시 분기로 파싱하는 방식으로 처리된다. SemIf(TypeSafe의 Jev 패턴을 오픈 모델로 재현한 프로젝트)는 텍스트를 생성하지 않고 모델의 옵션 로짓을 단 한 번의 forward로 읽어 결정을 낸다. SemIf는 4B 모델을 올릴 GPU를 전제로 한다. 본 리포트는 동일한 엔진을 0.6B 오픈 모델로 일반 CPU에서 돌리는 소규모 적응인 JEV-CPU를 기술한다. 메커니즘을 코드 수준에서 온전히 해부하고(§5), GPU가 없는 8 GB 머신에서의 측정을 보고한다. 8개 도메인의 정성적 결정, 입력 길이에 따른 지연 곡선, 그리고 거기서 도출한 실용 입력 상한이다. 방법론적 신규성은 주장하지 않는다. 기여는 엔지니어링과 실증에 있다. 이 방법이 디바이스에 독립적임을 보이고, 오픈웨이트 모델이 코드 수준에서 어떻게 결정 엔진이 되는지를 문서화하며, 메모리나 모델이 아니라 CPU 지연이 한계가 되는 지점을 정량화한다.
Qwen3-0.6B의 옵션 로짓에서 약 1초 만에 읽힌다 — 텍스트 생성은 없다.소프트웨어 에이전트가 내리는 결정은 대개 작고 타입이 정해져 있다. 큐를 고르고, 심각도를 정하고, 증거가
주장을 뒷받침하는지 판단한다. 챗 모델로도 답할 수 있지만, 답 문장을 생성한 뒤 다시 if로 파싱하는
방식은 느리고 취약하다. 결정-네이티브 대안은 옵션을 토큰으로 제시하고, 바로 그 토큰들에 대한 모델의
확률을 한 번의 forward로 읽는 것이다. TypeSafe의 비공개 서비스 Jev가 이 인터페이스를 알렸고,
SemIf가 이를 오픈 모델과 GPU로 재현했다.
본 리포트는 좁고 실용적인 질문을 던진다. GPU 없이, 아주 작은 모델로도 이 방법이 동작하는가? 그리고 어디에서 무너지는가?
텍스트를 샘플링하는 대신 LM의 다음-토큰 분포에서 범주형 결정을 읽는 것은 이미 잘 확립된 아이디어다 (verbalizer 기반 zero-shot 분류, 답-토큰 스코어링, NLI 크로스-인코더 등). 이 메커니즘 자체에는 새로움이 없다. JEV-CPU는 SemIf의 CPU 적응이자 실증 연구이고, SemIf 역시 Jev 패턴을 독립적으로 재현한 것이다. Jev와 TypeSafe는 각 소유자의 자산이며 본 작업과는 무관하다.
하나의 결정은 하나의 레코드다. state(증거), question(기준), 그리고 2–16개의 타입드
options로 이루어진다. direct 스코어링 경로는 SemIf 그대로이며 다음과 같다.
A, B, C…를 붙인다.
시스템 턴이 "대문자 한 글자만" 답하도록 제약하므로, 생성될 다음 토큰이 곧 답이 된다.지연은 생성 길이가 아니라 프롬프트 prefill이 지배한다. JEV-CPU가 CPU에서 쓸 만한 이유가 바로 이 성질이다. 구체적인 코드는 §5에서 해부한다.
SemIf가 GPU를 강제하는 곳은 정확히 한 군데다. 로더가 단일 CUDA 디바이스를 확인하고
device_map={"":"cuda:0"}, bfloat16으로 모델을 올린다. 그 뒤의 코드는 전부 디바이스에
독립적이다. 스코어링 코드는 device = next(model.parameters()).device를 따르고, 유일한 CUDA 전용
호출인 torch.cuda.synchronize()는 if device.type == "cuda"로 감싸여 있어 CPU에서는
아무 일도 하지 않는다. 따라서 JEV-CPU는 로더만 바꾸고(CPU / float32) SemIf의 원본 스코어링을 그대로
쓴다. 표준 라이브러리 웹 서버가 3분할 UI로 이를 노출한다.
이 절이 본 리포트의 실증적 핵심이다. "JEV"/결정 엔진은 파인튜닝도 새 가중치도 아니다. 평범한 오픈웨이트 causal
LM을 호출하는 방식을 바꿔, 타입드 결정이 단 한 번의 forward에서 떨어지게 하는 것이다. 아래는 JEV-CPU에서
실제로 도는 전체 파이프라인을 함수 단위로 해부한 것이다. 스코어링 코드는
SemIf의 것을 그대로 실었고, JEV-CPU는 로더만 바꾼다
(§5.9). 스니펫 출처는 src/semif_phase1/{core,direct}.py이며 코드 주석은
원문을 유지한다.
하나의 결정은 여덟 단계를 거친다. 어느 단계도 텍스트를 생성하지 않는다.
record {state, question, options}
→ validate_row (5.2 엄격한 스키마 검증)
→ direct_messages (5.3 옵션 → 레터, 챗 턴 구성)
→ encode_prompt (5.5 챗 템플릿 → 토큰 ids)
→ _slot_ids (5.4 각 레터는 깨끗한 1토큰)
→ boundary check (5.5 답 경계가 안정적인지)
→ _forward (5.6 단 한 번의 forward, 마지막 위치 로짓)
→ 슬롯 취합 + softmax (5.8 옵션별 확률 → arg-max)
⇒ 결정, forward 약 1회
입력은 하나의 평범한 레코드다. 검증은 엄격해서, 이후 단계에서 조용히 강제 변환되거나 잘리는 일이 없다.
def validate_row(row):
required = {"id", "state", "question", "options"}
if not required <= row.keys(): raise ValueError(...)
json.dumps(state, ensure_ascii=False, allow_nan=False) # finite JSON
if not isinstance(options, list) or not 2 <= len(options) <= len(LETTERS): raise ValueError(...)
state는 비어 있지 않은 문자열/JSON 객체/배열이고, 옵션은 2–16개이며 각각
{id, description}, id는 서로 달라야 한다.
각 옵션에 대문자 레터를 부여하고, 레코드를 두 턴짜리 챗으로 만든다. 시스템 턴이 레터 한 글자 외에는 아무것도 못 내게 하므로, 다음 토큰 하나가 결정 전체를 담는다.
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)}]
판독은 각 답 레터의 확률을 비교하므로, 레터마다 깨끗한 어휘 슬롯 하나가 필요하다. 정확히 1토큰으로 round-trip 되고 서로 충돌하지 않아야 한다.
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
챗 템플릿을 생성 프롬프트를 켜고 thinking을 끈 채 적용한 뒤, 두 불변식을 확인한다. 프롬프트가 토큰 예산에 들고 (자르지 않는다), 답 레터를 붙였을 때 토큰화가 정확히 그 슬롯 하나만 늘어난다(프롬프트/답 경계가 안정적이다).
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("exceeds limit; no truncation")
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) # sha256, 감사용
모델을 한 번만 돌려 마지막 위치, 즉 다음-토큰 분포만 취한다. logits_to_keep=1은
전체 시퀀스 로짓 텐서 생성을 피한다. 샘플링도 루프도 없다.
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, :] # (batch, vocab); 샘플링 없음
스코어링 함수는 모델이 올라간 디바이스를 읽고, CUDA일 때만 동기화한다. 이 두 줄이 같은 코드가 GPU에서도 CPU에서도 수정 없이 도는 이유의 전부다.
device = next(model.parameters()).device # 모델을 따라감
if device.type == "cuda": torch.cuda.synchronize(device) # CPU에선 no-op
전체 어휘 로짓 벡터에서 옵션 레터 토큰만 골라 softmax를 취하면, 선언된 집합에 조건부인 옵션별 확률이 나온다. 선택은 그 arg-max다.
def score(model, tokenizer, row, metadata, max_tokens=4096):
ids, slots, prompt_hash = encode_prompt(tokenizer, row, max_tokens)
device = next(model.parameters()).device
inputs = {"input_ids": torch.tensor([ids], device=device),
"attention_mask": torch.ones((1, len(ids)), device=device)}
if device.type == "cuda": torch.cuda.synchronize(device)
with torch.inference_mode():
vocabulary = _forward(model, inputs)[0].float() # (vocab,)
selected = vocabulary[slots].cpu().tolist() # A, B, C, ... 위치의 로짓
return {"option_ids":[o["id"] for o in row["options"]],
"option_logits": selected, "probabilities": softmax(selected), ...}
이것이 "JEV화"의 전부다. 표준 AutoModelForCausalLM에 생성을 시키지 않고, 다음-토큰 로짓을 한 번
물어본 뒤, 옵션 슬롯들의 arg-max를 결정으로 삼는다.
SemIf — load_causal_model (GPU) | JEV-CPU — load_causal_model_cpu |
|---|---|
|
|
§5.7–§5.8이 디바이스를 모델에서 가져오므로 CPU에 올리기만 하면 된다. 동일한 스코어링 코드가 CUDA sync만 건너뛴
채 실행된다. 다른 한 가지 노브는 브레인 교체다. 답 레터가 단일 토큰인 causal LM이면 무엇이든 MODEL로
지정할 수 있다.
여러 기준이 같은 state를 판단할 때, shared.py는 state를 KV 캐시에 한 번 prefill하고
cache.reorder_cache(...)로 브랜치에 복제한 뒤, 벡터 logits_to_keep로 모든 기준의 답 위치를
한 번의 배치 forward로 평가한다. 프리필은 한 번, 결정은 여럿이다. 이 경로 역시 CUDA에서만 sync하며, 고처리량 서빙을
싸게 만드는 핵심이다(§8.1).
CPU에서 Qwen/Qwen3-0.6B를 float32로 사용해 동일한 엔진을 8개 도메인에서 실행했다
(그림 1). 결정당 약 1초가 걸렸다.
| 도메인 | 기준 | 결정 | Forward |
|---|---|---|---|
| 고객 지원 | 감성 | negative — 97.4% | ~1.1 s |
| 고객 지원 | 팀 라우팅 | billing — 100% | ~1.0 s |
| 콘텐츠 모더레이션 | 정책 위반? | violation — 99.3% | ~1.1 s |
| 콘텐츠 모더레이션 | 권장 조치 | warn — 69.2% | ~1.0 s |
| 코드리뷰 트리아지 | 머지 리스크 | high — 99.8% | ~1.2 s |
| 코드리뷰 트리아지 | PR 처리 | block — 94.9% | ~1.1 s |
| 인시던트 / DevOps | 심각도 | sev1 — 100% | ~1.2 s |
| 인시던트 / DevOps | 온콜 호출? | page_now — 100% | ~1.1 s |
| 이메일 의도 | 주요 의도 | sales — 100% | ~1.2 s |
| 컴플라이언스 게이트 | 변경 티켓 필요? | required — 100% | ~1.1 s |
| 대출 / 신용 리스크 | 신용 리스크 | high — 100% | ~1.2 s |
| 대출 / 신용 리스크 | 권장 결정 | approve — 82.8% ⚠︎ | ~1.1 s |
| 지원 우선순위 | 우선순위 | p1 — 100% | ~1.2 s |
벤치마크가 아니라 예시적 단일 실행이다. 대출 결정 행은 소형 모델의 오답을 의도적으로 드러낸 예시다(§9 참조).
두 수치가 자주 혼동된다. 모델의 컨텍스트 창은 40,960 토큰으로, 0.6B치고도 크다. SemIf 엔진은 결정당 기본 4,096 토큰 상한을 둔다(안전장치이며 조정 가능하다). CPU에서는 둘 다 실제 제약이 아니다. 8 GB CPU 머신에서 입력을 키워 가며 측정한 단일 결정은 다음과 같다.
| 입력 토큰 | Forward (CPU) | 최대 RAM |
|---|---|---|
| 254 | 2.5 s | ~3.5 GB |
| 731 | 5.6 s | ~3.5 GB |
| 1,363 | 11.9 s | ~3.5 GB |
| 2,629 | 26.0 s | ~3.5 GB |
| 5,165 | 66.1 s | ~3.5 GB |
| 7,697 | 117.4 s | ~3.5 GB |
7,697 토큰에서도 RAM은 약 3.5 GB로 평평했고 OOM은 없었다. 즉 여기서의 한계는 메모리나 모델이 아니라 prefill 지연(대략 제곱으로 증가)이다.
Qwen3-0.6B는 SemIf 사다리에서 가장 작은 모델로, 정확도의 바닥에 해당한다. SemIf의 평가 결과는
다음과 같다.
| 브레인 모델 | 크기 | 저작 balanced acc. | TypeSafe subset 일치 |
|---|---|---|---|
| Qwen3-0.6B (JEV-CPU 기본) | 0.6 B | 0.440 | 0.407 |
| MiniCPM5-2B | 2 B | 0.686 | 0.637 |
| Qwen3.5-4B | 4 B | 0.813 | 0.845 |
엔진은 모델에 독립적이므로 상위 모델로 올리는 것은 한 줄 변경(MODEL = …)이면 된다. 대가는 이
머신의 한계를 넘는 RAM과 연산이다. 방법은 어디서든 돌아가고, 정확도는 어떤 모델을 가리키느냐에 따라 달라진다.
정확도(§8)와 CPU 지연(§7)은 흔히 서로 맞바꿔야 하는 관계로 여겨지지만, GPU는 이 둘을 동시에 푼다.
종합하면, GPU에서 서빙되는 더 큰 오픈웨이트 모델(4B 이상)은 동시에 더 정확하고, 훨씬 큰 입력을 받으며, 초당 많은 결정을 처리한다. 그것도 타입드하고 감사 가능한 출력으로, 파싱할 것도 없이 말이다. 이것이 상용(잠재적 으로 상업적) JEV의 모습이다. 저지연·고처리량의 호스팅형 semantic-if 서비스다. JEV-CPU가 바닥 이라면(어느 머신에도 이식할 수 있다), GPU로 서빙되는 더 큰 모델은 같은 엔진을 제품으로 끌어올리는 천장 이다.
결정-네이티브 로짓 판독 추론은 GPU나 큰 모델에 묶이지 않는다. §5가 코드로 보였듯, 평범한 오픈웨이트 모델은 로더 변경만으로 결정 엔진이 된다. 그렇게 하면 SemIf 엔진은 0.6B 모델로 일반 CPU에서 돌아가고, 여러 도메인을 결정당 약 1초에 처리하며, 기본 토큰 상한을 훌쩍 넘겨도 메모리가 안정적이다. 이 하드웨어에서 정직한 한계는 지연이다. 약 7,700 토큰을 넘는 입력은 약 120초 선을 넘고, 나아지는 것은 컨텍스트나 메모리가 아니라 소형 모델의 정확도다. JEV-CPU는 재현 가능한 바닥이다. 같은 엔진에 GPU 위의 더 큰 오픈웨이트 모델을 얹으면 천장(§8.1)에 닿는다. 정확하고 처리량이 높고 지연이 낮은 semantic-if 서비스, 곧 상용 JEV다.
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: 옵션별 확률
python server.py # 웹 UI: http://localhost:8080