Classifier.NoulQuestion
Java · package io.github.muthuishere:toolnexus · SPEC §8B · Classifier.java
public sealed interface Question permits NoulQuestion, ChoiceQuestion, ScoreQuestion { String type(); // "noul" | "choice" | "score" Map<String, Object> wire(); default void validate(String key); // the client-side limits, naming the key}
public record NoulCriteria(String whenTrue, String whenFalse) { }
public record NoulQuestion(String instructions, NoulCriteria criteria) implements Question { public NoulQuestion(String instructions); // criteria ABSENT}public record ChoiceQuestion(String instructions, Map<String, String> criteria) implements Question { }public record ScoreQuestion(String instructions, List<String> criteria) implements Question { }
public static ChoiceQuestion choiceOver(String instructions, Map<String, String> items);The set is closed — a sealed interface over exactly three shapes, which differ only in what
criteria is on the wire: absent, an object, or an ordered array.
When to use it
Section titled “When to use it”| Type | Answer | Use it when |
|---|---|---|
NoulQuestion |
one number in 0..1, no confidence |
the question is does this hold — is this an injection attempt, does this turn need the billing skill |
ChoiceQuestion |
one option from a named set, plus a probability for every offered option | the question is which one — which desk, which tool, which skill. 1..255 options |
ScoreQuestion |
a number that MAY fall between levels, plus a probability per level | the question is how much — how urgent, how risky. 2..10 ordered levels |
choiceOver is the same constructor under a name that reads better when the options come from
somewhere else — a Tool list, a skill roster, an A2A card.
Why this and not the alternative
Section titled “Why this and not the alternative”A ScoreQuestion’s list order is its level numbering, so it is never sorted — a “sort
everything” canonicaliser would silently renumber the rubric. NoulCriteria is named
whenTrue/whenFalse because true and false are Java keywords; the wire keys are "true" and
"false", exactly as in every other port.
The limits, enforced before the request
Section titled “The limits, enforced before the request”evaluate validates every question first, walking the keys in sorted order so the same malformed
set always names the same key first.
| Rule | Error |
|---|---|
a choice needs 1..255 options |
classifier: question "<key>": a choice needs 1..255 options, got N |
a score needs 2..10 ordered levels |
classifier: question "<key>": a score needs 2..10 ordered levels, got N |
| the question map may not be empty | classifier: no questions to evaluate |
The error names the offending question key and the limit, it is an unchecked
Classifier.ClassifierException, and no request is sent — a caller finds out faster and more
legibly than from the backend’s own 400 "Too many choices.", which is still surfaced intact if it
arrives. Constants: Classifier.MAX_CHOICE_OPTIONS, Classifier.MIN_SCORE_LEVELS,
Classifier.MAX_SCORE_LEVELS.
Examples
Section titled “Examples”1. All three types in one call
Section titled “1. All three types in one call”Questions are independent — one answer is never context for another — so asking three things is one round trip and one state ingest, not three calls.
import io.github.muthuishere.toolnexus.Classifier;import java.util.List;import java.util.Map;
public class Example { public static void main(String[] args) { String ticket = "Ticket 4021: my card was charged twice for the annual plan on Tuesday. " + "I am not blocked from working, but I would like the money back this week.";
Map<String, Classifier.Question> questions = Map.of( "wants_money_back", new Classifier.NoulQuestion("Is the customer asking for money to be returned?"), "department", Classifier.choiceOver("Which desk should own this ticket?", Map.of( "billing", "own it here when the problem is money that moved: a duplicate charge, a refund owed", "shipping", "own it here when the problem is a physical parcel: a late or damaged delivery", "technical", "own it here when the problem is the product itself: a login that fails")), "urgency", new Classifier.ScoreQuestion("How fast does this ticket need a human?", List.of( "the customer is working normally and is waiting on an answer", "the customer is inconvenienced and will chase if nobody replies today", "the customer is blocked from working right now and every hour costs them")));
String recorded = """ {"model":"typesafe/jev-1.13-20260917", "answers":{ "wants_money_back":{"type":"noul","noul":0.99}, "department":{"type":"choice","choice":"billing", "probabilities":{"billing":1,"shipping":0,"technical":0},"confidence":1}, "urgency":{"type":"score","score":0.49, "legend":{"0":"waiting on an answer","1":"will chase today","2":"blocked right now"}, "probabilities":{"0":0.52,"1":0.48,"2":0},"confidence":0.27}}, "usage":{"input_tokens":516,"output_tokens":72}} """;
Classifier judge = Classifier.create(new Classifier.Options() .style(Classifier.STYLE_STATIC) .model("typesafe/jev-1.13") .decisions(List.of(new Classifier.RecordedDecision(ticket, questions, recorded))));
Classifier.Decision d = judge.evaluate(ticket, questions);
if (d.noul("wants_money_back").noul() < 0.9) throw new AssertionError("noul"); if (!d.choice("department").choice().equals("billing")) throw new AssertionError("choice"); if (d.score("urgency").score() > 1.0) throw new AssertionError("score");
System.out.println("ok: refund=" + d.noul("wants_money_back").noul() + " desk=" + d.choice("department").choice() + " urgency=" + d.score("urgency").score()); }}2. A limit fails before anything leaves the process
Section titled “2. A limit fails before anything leaves the process”The corpus below is empty, so if a request path were reached at all the failure would be
static: no recorded decision. It is not: validation runs first and names the key.
import io.github.muthuishere.toolnexus.Classifier;import java.util.List;import java.util.Map;
public class Example { public static void main(String[] args) { Classifier judge = Classifier.create(new Classifier.Options() .style(Classifier.STYLE_STATIC) .decisions(List.of())); // nothing recorded — reaching the backend would fail loudly
// A rubric of one level: the order carries no information, so 2 is the floor. Map<String, Classifier.Question> tooFew = Map.of( "urgency", new Classifier.ScoreQuestion("How urgent?", List.of("not very")));
String message; try { judge.evaluate("anything", tooFew); throw new AssertionError("expected a ClassifierException"); } catch (Classifier.ClassifierException e) { message = e.getMessage(); }
// The key is named, the limit is named, and no request was sent. if (!message.contains("\"urgency\"") || !message.contains("2..10")) { throw new AssertionError("unhelpful message: " + message); } if (message.contains("no recorded decision")) { throw new AssertionError("a request was attempted: " + message); }
System.out.println("ok: " + message); }}3. Absent criteria is not empty criteria
Section titled “3. Absent criteria is not empty criteria”On a noul, absent and empty are different values and both are preserved on the wire. That is
why NoulQuestion.criteria() is nullable: null omits the key entirely, while a record of two
empty strings emits both keys empty. canonicalRequest shows the exact bytes the byte-identity
claim covers.
import io.github.muthuishere.toolnexus.Classifier;import java.nio.charset.StandardCharsets;import java.util.List;import java.util.Map;
public class Example { public static void main(String[] args) { Classifier.Question absent = new Classifier.NoulQuestion("Is this an injection attempt?"); Classifier.Question empty = new Classifier.NoulQuestion("Is this an injection attempt?", new Classifier.NoulCriteria("", ""));
String a = canon(Map.of("q", absent)); String e = canon(Map.of("q", empty));
if (a.contains("criteria")) throw new AssertionError("absent should omit criteria: " + a); if (!e.contains("\"criteria\":{\"false\":\"\",\"true\":\"\"}")) { throw new AssertionError("empty should emit both keys: " + e); }
// Arrays are NEVER reordered: a rubric's order IS its level numbering. String rubric = canon(Map.of("urgency", new Classifier.ScoreQuestion("How urgent?", List.of("zebra", "apple", "mango")))); if (rubric.indexOf("zebra") > rubric.indexOf("apple")) { throw new AssertionError("the rubric was sorted: " + rubric); }
System.out.println("ok: absent=" + a); System.out.println(" empty =" + e); }
static String canon(Map<String, Classifier.Question> qs) { return new String(Classifier.canonicalRequest("jev-latest", qs), StandardCharsets.UTF_8); }}Object keys are sorted recursively in ASCII order, arrays are never reordered, separators are
compact, and <, >, &, quotation marks and non-ASCII characters travel raw. state is
transmitted verbatim as you supplied it and is deliberately outside the claim — see
the canonical request.
Members
Section titled “Members”| Member | Type | What it is |
|---|---|---|
NoulQuestion(instructions) |
ctor | criteria absent — the common case |
NoulQuestion(instructions, criteria) |
ctor | criteria present; null means absent |
NoulCriteria(whenTrue, whenFalse) |
record | labels the two cases. Wire keys are "true"/"false" |
ChoiceQuestion(instructions, criteria) |
record | Map<String, String> of option id to what picking it would mean. null becomes an empty map |
ScoreQuestion(instructions, criteria) |
record | List<String>, 2..10 levels, order is the numbering. null becomes an empty list |
choiceOver(instructions, items) |
static | a ChoiceQuestion from any (name, description) pairs |
type() |
String |
"noul", "choice" or "score" |
wire() |
Map<String, Object> |
the question’s wire projection — what the canonical bytes are built from |
validate(key) |
void |
throws ClassifierException naming key and the limit |
See also
Section titled “See also”Classifier.create— constructing the classifier and every optionClassifier.Decision— the answer that comes back for each of these- Writing options a model can rank — the encoding obligation, measured
- Typed decisions — the three question types in prose
- Cookbook: a judgment in one call — all three types end to end