JevJudge¶
A Model-as-a-judge built out of atomic questions rather than one rubric prompt. Every criterion is answered against the same state in a single call, in parallel, and each keeps its own threshold.
What it does¶
A judge holds a list of criteria. Most are a primitive plus what counts as passing it:
- a noul passes when its truth value reaches a minimum
- a score passes when it reaches a level on your rubric
- a choice passes when the selected label is one you accept
The rest are code checks: plain Java predicates for anything the input already settles, such as whether a tool was called. They never reach Jev.
judge(...) sends every question in one request and returns a JevVerdict: whether everything
passed, a finding per criterion, the raw response, and feedback synthesised from the
criteria that did not.
Class diagram¶
A judge is built from criteria, and each criterion pairs a primitive with its own pass condition:
classDiagram
direction TB
class Builder {
+noul(String, Noul, double) Builder
+score(String, Score, double) Builder
+choice(String, Choice, String[]) Builder
+check(String, Predicate~JevJudgeInput~, String) Builder
+criterion(JevCriterion) Builder
+minConfidence(double) Builder
+failOnError(boolean) Builder
+failFast(boolean) Builder
+failOnInconclusive(boolean) Builder
+build() JevJudge
}
class JevJudge {
+String QUESTION_FIELD$
+String ANSWER_FIELD$
+judge(String, String) JevVerdict
+judge(JevJudgeInput) JevVerdict
+judge(JsonContent) JevVerdict
+criteria() List~JevCriterion~
+builder(TypeSafeClient)$ Builder
}
class JevCriterion {
<<sealed interface>>
+name() String
}
class QuestionCriterion {
<<record>>
+String name
+Question question
+double minimum
+Set~String~ acceptedOptions
+Predicate~JevJudgeInput~ appliesWhen
+Dependency dependsOn
}
class CodeCriterion {
<<record>>
+String name
+Predicate~JevJudgeInput~ check
+String defect
}
class Question {
<<sealed interface>>
}
Builder ..> JevJudge : builds
JevJudge "1" *-- "1..*" JevCriterion : holds
JevCriterion <|.. QuestionCriterion
JevCriterion <|.. CodeCriterion
QuestionCriterion --> Question : the primitive asked
note for Question "permits Noul, Choice, Score"
The shape to take from this: a judge is a list of criteria and nothing else. It holds a
TypeSafeClient and spends at most one call per judge(...), whatever the number of
criteria. It makes none when no question applies, or when failFast skips it. Code checks run locally and add nothing to that — see what comes back for the other half.
How JevJudge, the advisor and the evaluator fit together¶
These three are a hub and two spokes, not a chain. JevJudge is the engine; the other
two are independent adapters that put it behind a Spring AI extension point. Neither spoke
uses the other.
flowchart TB
J["JevJudge<br/><small>criteria in, verdict out</small>"]
S["JevSelfRefineAdvisor<br/><small>CallAdvisor</small>"]
G["JevGuardrailAdvisor<br/><small>CallAdvisor</small>"]
E["JevEvaluator<br/><small>Spring AI Evaluator SPI</small>"]
S --> J
G --> J
E --> J
| Type | Use it when |
|---|---|
JevJudge (this page) |
you want a verdict in your own code, with the per-criterion findings |
JevSelfRefineAdvisor |
a ChatClient should judge its own answer and retry when it falls short |
JevGuardrailAdvisor |
a turn should be refused rather than retried — an unsafe answer is not a draft |
JevEvaluator |
existing code is written against Spring AI's Evaluator, and you want Jev behind it |
JevGuardrailAdvisor is the odd one out: it builds its own hazard batteries rather than
taking a JevJudge, because the questions and thresholds are prescribed by the guardrail
policy rather than supplied per use.
Why not just ask a chat model¶
Spring AI ships RelevancyEvaluator and FactCheckingEvaluator. Both prompt a second chat
model and come down to:
That fails in two directions. The model may answer in a form the parser does not expect, and one yes-or-no cannot say which of several things was wrong. A judge built from typed questions cannot come back malformed, and keeps the dimensions separate.
| A judge model | JevJudge |
|
|---|---|---|
| Verdict | prose to parse | typed answers |
| Dimensions | one overall rating | one threshold per criterion |
| Cost of a third check | a third prompt, or a longer one | negligible — same call |
| Feedback | generated, varies run to run | synthesised from your own rubric |
| Undecided | indistinguishable from failed | reported as INCONCLUSIVE |
| Could not judge | indistinguishable from failed | reported as ERROR |
Quick Start¶
JevJudge judge = JevJudge.builder(typeSafeClient)
.score("helpfulness", Score.builder()
.instructions("How well does `assistant_answer` address `user_question`?")
.level("Terrible: irrelevant or off-topic")
.level("Mostly unhelpful: misses the main point")
.level("Mostly helpful: minor gaps remain")
.level("Excellent: fully and correctly addressed")
.build(), 2.0d)
.noul("is_plausible", Noul.builder()
.instructions("Are the numeric values in `assistant_answer` physically plausible?")
.whenFalse("At least one value is impossible")
.build(), 0.7d)
.build();
JevVerdict verdict = judge.judge(question, answer);
if (!verdict.passed()) {
System.out.println(verdict.feedback());
}
The state a judge builds¶
judge(String question, String answer) builds a two-field state:
When there is more to judge against, build a JevJudgeInput. Its fields become the state
under fixed names, the same wherever a judge is used, so write your instructions against
them:
| Builder method | State field | Shape |
|---|---|---|
question(String) |
user_question |
string |
answer(String) |
assistant_answer |
string |
expected(Object) |
expected_output |
any JSON value: the reference to judge against |
context(String) / context(List<String>) |
supporting_context |
array, one entry per document |
toolCall(ToolCall) / toolCalls(List<ToolCall>) |
tool_calls |
array of {name, arguments, result} |
field(String, Object) |
the name you give | anything else |
JevVerdict verdict = judge.judge(JevJudgeInput.builder()
.question("Will I need an umbrella in Dublin tomorrow?")
.answer(answer)
.expected("Rain is likely; bring an umbrella.")
.field("search_required", true)
.toolCalls(toolCalls)
.build());
Empty context and tool-call lists are left out rather than sent as empty arrays, so a
criterion can tell "no evidence" from "evidence that says nothing". The constants live on
JevJudgeInput (QUESTION_FIELD, EXPECTED_FIELD, TOOL_CALLS_FIELD, ...).
For anything that is not a question-and-answer pair, pass the state yourself:
JevVerdict verdict = judge.judge(JsonContent.of(Map.of(
"passage", retrievedText,
"claim", claimUnderTest)));
Code criteria¶
Some criteria need no model, because the answer is already in the input: whether the agent searched when it had to, whether the answer parses, whether it matches the expected output exactly. Declare those as checks:
JevJudge judge = JevJudge.builder(typeSafeClient)
.check("matches_search_expectation",
input -> !input.toolCalls().isEmpty()
== Boolean.TRUE.equals(input.field("search_required", Boolean.class)),
"searched when no search was required, or skipped a required search")
.noul("is_grounded", groundedNoul, 0.7d)
.build();
A check is a Predicate<JevJudgeInput> plus the defect to report when it returns false.
- It never reaches Jev. Checks run locally before the call. Only the questions are sent, still in one request.
- It lands in the same verdict. A failed check is a
FAILEDfinding: it fails the verdict, its defect goes into the feedback and it shows insummary(). Itsanswerisnull, since there is no model answer. - A check that throws is a bug, not a verdict. The exception propagates out of
judge(...)before any request is made. - A judge needs at least one question. A judge of checks alone would be a plain predicate.
judge(JsonContent) works with checks too. They read the state through
JevJudgeInput.of(state) and its field(name, type) reader, which requires the state to be
a JSON object.
Do not ask a model a question code can answer
Reproducing LangChain's jev-as-a-judge benchmark, the single wrong judgement out of fifteen was a question a one-line comparison settles exactly. Moving it into a check raised accuracy from 0.8 to 1.0 and lowered variance.
Builder Configuration¶
| Builder method | Type | Default | Description |
|---|---|---|---|
noul(String, Noul, double) |
— | — | A criterion passing at or above a truth value. |
score(String, Score, double) |
— | — | A criterion passing at or above a rubric level. |
choice(String, Choice, String...) |
— | — | A criterion passing when the label is accepted. Validates the labels exist on the choice. |
check(String, Predicate<JevJudgeInput>, String) |
— | — | A code check: passes when the predicate returns true, otherwise reports the defect. |
criterion(JevCriterion) |
— | — | Add a pre-built criterion of either kind. |
minConfidence(double) |
double |
0.6 |
How much of a choice's or score's probability must support its verdict; below it, INCONCLUSIVE. See decisive, not peaked. Nouls are unaffected. |
failOnInconclusive(boolean) |
boolean |
false |
Treat an undecided criterion as a failure. |
failOnError(boolean) |
boolean |
false |
Treat a criterion the service could not answer (ERROR) as a failure. |
failFast(boolean) |
boolean |
false |
When a code check fails, skip the Jev call; the questions become NOT_APPLICABLE. |
feedbackRenderer(Function<List<JevFinding>, String>) |
— | JevJudge::defaultFeedback |
Replace the wording fed back to the model. |
Criterion names must be unique across both kinds, and a judge needs at least one question criterion.
Criteria that only sometimes apply¶
A question can be made conditional with appliesWhen. When the predicate returns false
the question is not sent, and its finding is NOT_APPLICABLE: it neither passes nor fails.
A groundedness check on a turn with no retrieved context is the typical case:
JevJudge judge = JevJudge.builder(typeSafeClient)
.criterion(JevCriterion.noul("is_grounded", groundedNoul, 0.7d)
.appliesWhen(input -> !input.context().isEmpty()))
.noul("is_relevant", relevantNoul, 0.7d)
.build();
When no question applies, no call is made and verdict.response() is null.
Criteria that depend on other answers¶
Rubrics branch. An answer that asks which Springfield is meant should not be marked down for
leaving out the forecast's timing. whenChosen and whenPassed make a question depend on
another criterion's outcome:
JevJudge judge = JevJudge.builder(typeSafeClient)
.choice("mode", Choice.builder()
.instructions("How does `assistant_answer` respond to `user_question`?")
.option("answered", "Gives the weather")
.option("clarification_needed", "Asks which place is meant")
.build(), "answered", "clarification_needed")
.criterion(JevCriterion.noul("has_details", detailsNoul, 0.7d)
.whenChosen("mode", "answered"))
.build();
- The branch costs nothing. Jev answers every question in one parallel call, so
has_detailsis asked anyway, and the dependency is resolved in Java afterwards. Other judge frameworks walk such a graph one model call per node. - When the choice lands elsewhere, the dependent is
NOT_APPLICABLE, with detailhas_details: does not apply, mode was "clarification_needed". It neither passes nor fails. - An undecided dependency (
INCONCLUSIVE,ERRORorNOT_APPLICABLE) makes its dependentsNOT_APPLICABLEtoo: nothing was selected for them to rely on. whenPassed("name")depends on a criterion having passed. That criterion can be a code check.- A dependency must be declared before its dependents, which rules out cycles. A
whenChosenlabel must be one of the choice's options. Both are checked when the judge is built.
appliesWhen and a dependency combine. appliesWhen is judged on the input before the call,
and a false result means the question is not sent at all. The dependency is judged on the
findings after the call.
Reading the verdict¶
judge(...) returns one verdict carrying one finding per criterion, so the dimensions stay
separate rather than collapsing into a number:
classDiagram
direction TB
class JevVerdict {
<<record>>
+boolean passed
+List~JevFinding~ findings
+SystemOneResponse response
+String feedback
+failures() List~JevFinding~
+inconclusive() List~JevFinding~
+errors() List~JevFinding~
+notApplicable() List~JevFinding~
+summary() String
}
class JevFinding {
<<record>>
+JevCriterion criterion
+Answer answer
+Outcome outcome
+String detail
+name() String
+isFailure() boolean
}
class Outcome {
<<enumeration>>
PASSED
FAILED
INCONCLUSIVE
ERROR
NOT_APPLICABLE
}
class Answer {
<<sealed interface>>
}
JevVerdict "1" *-- "1..*" JevFinding : one per criterion
JevFinding --> Outcome : how it landed
JevFinding --> Answer : what came back
note for Answer "permits NoulAnswer, ChoiceAnswer,\nScoreAnswer, UnknownAnswer"
response is the untouched SystemOneResponse, so the usage counts and the request id are
there when you need them. It is null when no question was asked: every question was not
applicable, or failFast skipped the call.
public record JevVerdict(boolean passed, List<JevFinding> findings,
@Nullable SystemOneResponse response, String feedback) {
List<JevFinding> failures();
List<JevFinding> inconclusive();
List<JevFinding> errors();
List<JevFinding> notApplicable();
String summary(); // passed=false [helpfulness=PASSED, is_plausible=FAILED]
}
Each JevFinding carries its criterion, the raw answer (null for a code check or a
criterion that did not apply), an Outcome, and a detail sentence ready to hand back to a
model. Only FAILED blocks the verdict:
| Outcome | Meaning | Blocks? |
|---|---|---|
PASSED |
the criterion was met | no |
FAILED |
the criterion was not met | yes |
INCONCLUSIVE |
too little probability supported the verdict | only with failOnInconclusive(true) |
ERROR |
the service returned no answer, or one this SDK cannot read | only with failOnError(true) |
NOT_APPLICABLE |
not asked (appliesWhen was false, or failFast skipped the call), or asked but its dependency (whenChosen, whenPassed) was not met, so its answer is set aside |
no |
ERROR and NOT_APPLICABLE findings are left out of the feedback, since neither is a
defect the model can fix. failOnInconclusive(true) and failOnError(true) make a finding
block by reporting it as FAILED: it then appears in failures() and in the feedback, not
in inconclusive() or errors().
Feedback is synthesised, not generated¶
Jev returns numbers, never prose, so the feedback is built by the SDK from the answer and the rubric you wrote. It is deterministic and more specific than a judge model's commentary:
helpfulness: rated "Mostly unhelpful: misses the main point" (1.20), needs to reach 2.00
which is "Mostly helpful: minor gaps remain"
Two details worth knowing. The level quoted is the one the score lands on, not the most
probable level — the two can disagree on a spread distribution, and quoting a label that
contradicts the number would be worse than useless. And the wording is formatted with
Locale.ROOT, so 0.70 does not become 0,70 on a comma-decimal JVM and change what the
model reads.
Low confidence is undecided, not failed¶
A score or choice is decided by where its probability lies. A score passes when at least
half of its probability sits on levels at or above minimum. A choice passes when at least
half sits on the accepted options, even if the single most likely label is another one.
Without probabilities, the score's value, or the selected label, decides.
Whether that verdict can be acted on is not a question of how peaked the distribution is,
but of how much of it supports the verdict: the share on the passing side when it passes,
on the failing side when it fails. When that share is below minConfidence (0.6 by default:
a clear majority, where a coin flip is 0.5), the criterion is INCONCLUSIVE, and it does not
block unless you set failOnInconclusive(true).
The difference matters. This distribution, recorded live, has a confidence of only 0.51
because it is split between two levels, yet both levels pass a minimum of 2:
A criterion the service returns no answer for is reported as ERROR rather than thrown, so a
partial response costs you that one criterion, not the whole call.
See Also¶
- JevSelfRefineAdvisor — judge and retry inside a
ChatClient - JevEvaluator — the same judge behind Spring AI's
EvaluatorSPI - Confidence
- The Three Primitives