Archived design for validation · 2026-09-19

Seating Jev (orq classify) as a judge in evaluatorq

Archive
This visual captures the design before implementation. Its “Current” and “New” labels, file line numbers and rejected alternatives are historical. For current behavior, use the Classify judges guide.
TL;DR — The Orq router now serves POST /v3/router/classify with typesafe/jev-latest: typed questions (yes/no, choice, score) over a state, returning probabilities and a priced usage block in ~0.7 s at $0.042 per million input tokens. Today that model id 400s as a judge, because run_judge only knows Chat Completions and Responses. The change adds a third endpoint inside run_judge, chosen from the model catalogue, and lets llm_jury build the classify question from the panel-level fields it already has. A judge stays a string id, so Jev seats next to LLM judges on the same panel with no schema change to judges, presets, replacements or cyclic rotation.

P1 must be right before code is written   P2 defaults chosen, cheap to change later

1 · Endpoint routing inside run_judge P1

The one shared seam every judge caller goes through (common/judge.py:510). It already picks Responses vs Chat from the catalogue. Jev becomes a third branch keyed on the catalogue's metadata.supports_classify, and only when the client routes through the Orq router. When the catalogue is empty or its fetch failed, a hardcoded set holding typesafe/jev-latest answers instead and warns once, so an outage never sends Jev to chat.

Current
Current judge routing sends Responses-capable models to Responses, other prompted models to Chat Completions, and incorrectly sends Jev to Chat Completions. run_judge(model, cfg, …) llm_jury · pairwise · redteam model catalogue lookup qualified_model → supports_responses? Responses schema-enforced, priced yes Chat Completions .parse → json_object no / 400 typesafe/jev-latest model_type "classify" no supports_responses → falls to Chat Completions HTTP 400 chat has no classify model EvaluatorResponsePayload{value, explanation, abstain} parsed from model text
New
New judge routing sends classify models to the classify endpoint, Responses-capable models to Responses, and other prompted models to Chat Completions. run_judge(model, cfg, classify=…) new keyword: ClassifyQuestion | None model catalogue lookup supports_classify? → supports_responses? Classify POST /v3/router/classify classify Responses unchanged Chat Completions unchanged classify model, no question? JudgeOutcome error, no silent fallback value · explanation (synthesised) · abstain=False value derived in code from noul / choice / score · endpoint="classify" · span op "classify"
Why this seam and not a separate run_classify_judge or llm_classify() judge.py:353, :510

Endpoint choice already lives here (_resolve_responses_model at judge.py:353 gates on the catalogue and the router). All five run_judge call sites (jury, pairwise, two redteam paths) get Jev by naming the model. A separate function would have needed every parameter the parent already takes, and a separate evaluator factory would have made a mixed panel impossible.

Retry stays with_retry around the whole attempt, same as today: 429 and 529 back off, everything else classifies to JudgeError. timeout_ms applies. SDK retries stay off at this boundary.

2 · Verdict modes map one-to-one onto Jev question types P1

Nothing about the jury contract changes. Each existing llm_jury mode is one Jev primitive; the derivation of value is code, never a model opinion.

llm_jury modeJev questionJev answervalue (new)passed (unchanged)
Boolean
categorical, no labels
noul
instructions = criteria
{"noul": 0.99}
probability, no confidence
noul >= threshold
threshold default 0.5, now meaningful in boolean mode for Jev
the boolean
Labeled
categorical + labels
choice
criteria = {label: description | null}
{"choice": "neutral", "probabilities": {…}, "confidence": 0.96} the choice string value in passing_labels
Numeric
numeric
score
criteria = levels (2 to 10 ordered descriptions)
{"score": 1.97, "legend": {…}, "probabilities": {…}, "confidence": 0.96}
score is a probability-weighted mean over level index 0…n-1
score / (n - 1) into (0, 1)
score_range must stay at its default
value >= threshold
Pairwise
llm_jury_pairwise
choice over A / B / tie as labeled the choice, un-swapped as today n/a (winner)
Jev returns no text and never abstains. evaluatorq synthesises explanation from the returned numbers. The complete validated answer travels through EvaluationResult.raw_output["jury"]votesrepetitionsraw_output; reported confidence and probabilities also become judge.confidence / judge.probabilities span attributes. Low confidence is not mapped to an abstention. send_results_to_orq() strips raw_output, so the hosted Orq experiment view does not receive these details.

3 · Prompt vs state: what each judge kind sees P1

An LLM judge gets a rendered prompt. Jev gets instructions (the criteria) plus a state object. The state is built from the template's {{…}} placeholders, resolved through the same template engine and keyed by dotted path, with criteria excluded. An explicit state_fields list replaces the placeholder scan.

One panel, two renderings of the same replacements
The same panel fields become a rendered prompt for an LLM judge and structured instructions, criteria, and state for a classify judge before both results enter jury aggregation. panel-level fields criteria = "Is the reply polite?" labels = {rude: "hostile…", neutral: None} prompt = DEFAULT_TEMPLATE (or custom) replacements = build_eval_replacements(…) LLM judge · openai/gpt-5.4-mini · unchanged path system: "value must be one of: rude (hostile…), …" user: render_template(prompt, replacements) schema: value: Literal[labels] + Field(description) gains: label descriptions in schema and system prompt Jev · typesafe/jev-latest · new path instructions: criteria criteria: {rude: "hostile…", neutral: null} state: {"input.all_messages": …, "output.response": …, "input.expected_output": …} state keys = the template's {{…}} placeholders, minus criteria {explanation: "…", value: "neutral"} parsed from model text {choice: "neutral", confidence: 0.96} value = choice · synthesised explanation same JuryVote → run_jury aggregates
tone = llm_jury(
    name="tone",
    criteria="Is the reply polite and on-topic?",
    judges=["openai/gpt-5.4-mini", "anthropic/claude-sonnet-4-6", "typesafe/jev-latest"],
    labels={"rude": "hostile or dismissive", "neutral": None, "friendly": "warm and helpful"},
    passing_labels=["neutral", "friendly"],
)
# LLM judges: DEFAULT_TEMPLATE rendered, labels + descriptions in schema and system prompt.
# Jev: instructions=criteria, criteria=labels, state = the template's placeholders minus criteria.
tone = llm_jury(
    name="tone",
    prompt="# Reply\n{{output.response}}\n\n# Conversation\n{{input.all_messages}}\n\nRate the tone.",
    criteria="Is the reply polite and on-topic?",   # both allowed only when a Jev judge is seated
    judges=["openai/gpt-5.4-mini", "typesafe/jev-latest"],
    labels={"rude": "hostile or dismissive", "neutral": None, "friendly": "warm and helpful"},
    state_fields=["output.response"],             # optional: override the placeholder scan
)
# LLM judges render `prompt` exactly as before. Jev never sees the prompt.
helpfulness = llm_jury(
    name="helpfulness",
    criteria="How helpful is the answer?",
    judges=["openai/gpt-5.4-mini", "typesafe/jev-latest"],
    verdict_kind="numeric",
    levels=["does not address the question", "partially addresses it", "fully answers it"],
    threshold=0.7,                     # score_range stays (0, 1) — non-default raises at construction
)
# Jev: score in [0, 2] → value = score / 2. LLM judges: levels go into the value field description.
POST https://my.orq.ai/v3/router/classify      (0.7 s, 2026-09-19)
{"model": "typesafe/jev-latest",
 "state": {"input.all_messages": "...", "output.response": "Paris."},
 "questions": {"verdict": {"type": "choice", "instructions": "Grade the answer",
                           "criteria": {"correct": "fully correct", "partial": "partly", "wrong": "incorrect"}}}}

{"model": "jev-latest",
 "answers": {"verdict": {"type": "choice", "choice": "correct",
                         "probabilities": {"correct": 1, "partial": 0, "wrong": 0}, "confidence": 1}},
 "usage": {"input_tokens": 379, "output_tokens": 68,
           "input_cost": 0.00001592, "output_cost": 0, "total_cost": 0.00001592}}

What changes for an LLM judge on the same panel

The prompted verdict shape stays {explanation, value, abstain}, with one JuryVote per judge. The local EvaluationResult.raw_output["jury"] record now adds optional raw_output on each repetition: the complete validated answer for a classify judge, None for a prompted judge. Three inputs change, all additive.

SurfaceTodayNew
Verdict schema, labeled mode value: Literal["rude", "neutral", "friendly"] same Literal, plus Field(description="rude: hostile or dismissive; neutral; friendly: warm and helpful"). The enum is still enforced by the provider; the description is what the model reads.
Verdict schema, numeric mode value: float, prompt says "between 0 and 1" value: float with Field(description="0.0 = does not address the question; 0.5 = partially addresses it; 1.0 = fully answers it") when levels is set. Without levels, unchanged.
Default system prompt "value must be exactly one of: rude, neutral, friendly" "value must be exactly one of: rude (hostile or dismissive), neutral, friendly (warm and helpful)". A caller-supplied system_prompt is untouched.
User prompt render_template(prompt or DEFAULT_TEMPLATE, replacements) identical. When prompt and criteria are both set, the LLM judge renders prompt; criteria only reaches it if the template references {{criteria}}.
Output, cost, tracing Responses or Chat, priced by the router identical. No new span attributes on LLM judge spans; judge.confidence / judge.probabilities appear only on Jev spans.

A panel without a Jev judge and without levels or dict labels produces byte-identical requests to today.

4 · What llm_jury gains, field by field P2

FieldTodayNewWho reads it
judgeslist[str]unchanged; a Jev id is just another stringboth
criteriasubstituted into {{criteria}}also Jev instructionsboth
promptexclusive with criteriamay coexist with criteria when a Jev judge is seatedLLM only
labelslist[str]also dict[str, str | None] (a list still works, no descriptions; not breaking); descriptions land in the Literal field's description and the system prompt, and in Jev criteriaboth
levelsnew, list[str] of 2 to 10 ordered descriptions; required for a Jev judge in numeric mode; feeds the LLM value description tooboth
state_fieldsnew, list[str] of dotted paths; replaces the placeholder scanJev only
score_rangeany increasing pairmust be default (0, 1) when a Jev judge is seatedboth
thresholdnumeric modealso boolean mode on Jev (noul >= threshold)both
temperature, max_tokens, reasoning_effort, structured_output, extra_kwargs, extra_body, system_prompt, apisentignored by Jev, one warning via warn_unread_config_fields; still sent to LLM judges on the panelLLM only
repetitionsN calls per judgeworks; warns once on a Jev judge (near-deterministic, pays for the same answer)both

Construction-time checks use the same hardcoded classify set (the catalogue is async, so a constructor cannot consult it): prompt without criteria, numeric without levels, non-default score_range. Run time uses the catalogue first, the set as fallback.

5 · Decisions to validate, most consequential first

These were settled in the grill. Each names the alternative that was rejected, so a disagreement is one line to state.

D1 Branch lives in run_judge, keyed on the catalogue P1

Every caller gets Jev by naming the model. Redteam and simulation inherit the seam without changes.

Rejected: a separate run_classify_judge (would need every parent parameter anyway); an llm_classify() factory (no mixed panels).
D2 Judges stay string ids; no typed judge entries P1

Panel-level fields serve both judge kinds. Per-judge settings remain a separate design problem, and a later per-judge spec covers Jev like any other judge.

Rejected: judges=[…, ClassifyJudge(model, instructions, state_fields)].
D3 State = template placeholders as dotted keys, criteria as instructions P1

Only what the template references reaches Jev, keeping the 32k state limit in view and matching how the model is meant to be used. state_fields is the explicit override.

Rejected: sending the rendered prompt string as state (duplicates criteria inside the state); a fixed {input, output, expected_output} object.
D4 Values derived in code: noul vs threshold, score rescaled to (0, 1) P1

Jev's score spans level index 0…n-1; dividing by n-1 puts it on the same scale as an LLM judge asked for a 0 to 1 float, so mean/median aggregation across a mixed panel is meaningful.

Rejected: hardcoded 0.5 for noul (threshold exists); allowing any score_range and rescaling into it.
D5 Labels and levels become part of the LLM verdict schema, not only the prompt P2

value: Literal[labels] already enforces the set (llm_jury.py:45); this adds Field(description=…) with the descriptions, mirroring the orquesta-web evaluator runner's create_model approach.

D6 Synthesised explanation; structured answers go to repetitions and spans P2

The implemented explanation is a one-liner such as "choice='neutral' (confidence 0.96)". Read complete validated answers through EvaluationResult.raw_output["jury"]votesrepetitionsraw_output; reported confidence and probabilities also remain on judge spans. Fields the provider did not report are omitted.

Rejected: an empty explanation or keeping structured details only on spans. Returned and local saved results retain the answers; send_results_to_orq() still strips raw_output from the hosted experiment upload.
D7 No confidence-to-abstain mapping, no new knobs P2

Reported confidence is retained per repetition in local results and on judge spans. It does not change the verdict or reach the hosted experiment upload.

D9 Catalogue outage falls back to a hardcoded classify set P1

Today only typesafe/jev-latest is a classify model. supports_classify(model) reads the catalogue entry on the qualified id and, when the catalogue is empty or failed, answers from that set with one warning. The same set drives the construction-time checks, so there is one detector, not a prefix test plus a catalogue.

Rejected: a typesafe/ prefix test (provider-prefix detection had already routed models incorrectly); raising when the catalogue is unavailable (the motivating 400 stays reachable during an outage).
D8 Ignored config fields warn once instead of raising P2

A preset seating Jev next to GPT judges sets reasoning_effort for the panel; rejecting would break that seating.

6 · Out of scope, and gotchas already known

7 · Files touched

FileChange
common/judge.pyClassifyQuestion model, classify= keyword on run_judge, _classify_judge leg, endpoint literal gains 'classify', EvaluatorResponsePayload design note names both producers
common/llm_call.pyexecute_classify: AsyncOpenAI.post('/v3/router/classify', ...) so 429/529 raise SDK error types and with_retry plus _classify work unchanged; usage + cost onto the span, trace headers
common/model_catalogue.pyModelInfo.supports_classify: bool = False appended last (positional constructors keep working), parsed from metadata.supports_classify; supports_classify(model) lookup on the qualified id with the hardcoded fallback set
common/template_engine.pyextract_template_paths(template) and a per-path resolver, used by render_template and the state builder
common/tracing.pyspan attributes judge.confidence, judge.probabilities; operation classify
llm_jury.pylabels dict form, levels, state_fields, prompt+criteria rule, question building, value derivation, construction checks, warnings
tests/fake classify transport; per-mode value derivation; error branches (no question, 400, timeout); guardrail allowlist for the new call site
docs/llm-as-a-jury.md, docs/pairwise-judging.md, CHANGELOG.md"Jev as a judge" section, pairwise note, changelog entry