Classification and Proposition Assessment
Classification picks one category ID from a closed set that your application supplies.
Your application can map that ID to an enum constant or a class token such as Rabbit.class.
The provider sees the input, the instructions, and each category’s ID and description; nothing extracts fields or constructs an instance.
Use object extraction separately when you need a populated object.
The application decides what to do with the result: route work, ask for more information, or revise a choice.
Application and model responsibilities
Application: input + instructions + canonical categories
|
v
ClassificationService
|
v
selected | no match | inconclusive | failure
|
v
Application: map selected ID to enum or class token
|
v
Application policy
Define the Choices Once
A ClassificationSpec holds what the model is asked and the categories it picks from.
Build it once and reuse it for every input:
ClassificationSpec departments = ClassificationSpec.builder()
.asking("Which team should handle this?")
.category("billing", "Payments, invoicing, refunds")
.category("technical", "Bugs, outages, integrations")
.build();
In Kotlin, classificationSpec { } builds the same spec:
val departments = classificationSpec {
asking("Which team should handle this?")
category("billing", "Payments, invoicing, refunds")
category("technical", "Bugs, outages, integrations")
}
asking sets the instructions: what the model is asked, such as "Which kind of animal is this?".
They are required, and a blank value throws IllegalArgumentException.
At least one category is required, and category IDs must be nonblank and unique.
Each category is a Category with a stable id and a description of when it applies.
getInstructions() and getCategories() read them back.
ClassificationRequest.of(input, spec) pairs a spec with the text to classify.
A classification spec is a decision spec with one choice question, so a decision service answers it too.
The builder names that question classification.
ClassificationSpec.of(question) wraps a choice question you already have and keeps its name.
A ClassificationRequest is a DecisionRequest, and response.answer(spec.getQuestion()) reads the ClassificationResult from a decision response.
The spec equals a DecisionSpec that holds the same question, and both write the same JSON.
See .
For dog, cat, and rabbit, describe “bunny” as an alias in the rabbit description; it is not a fourth category. Descriptions guide the model, but do not create a separate alias registry or guarantee synonym recognition.
CategoryMapping<T> takes the instructions and pairs each category with an application value.
spec() returns the ClassificationSpec built from them, and request(input) returns a request for that spec.
Values may be enum constants or class tokens such as Rabbit.class.
Class tokens stay in the application; the provider receives category definitions, not authority to instantiate objects.
fromEnum(enumType, instructions, description) uses enum names as IDs, regardless of an overridden toString().
Requests and mappings copy their structural collections; mapped values themselves remain caller-owned references.
The following repository tests use AnimalKind with DOG, CAT, and RABBIT constants, and Dog, Cat, and Rabbit classes implementing Animal.
They create selected results locally to test mapping; they do not call a provider.
@Test
void enumMapping() {
var mapping =
CategoryMapping.fromEnum(
AnimalKind.class,
"Which kind of animal is this?",
animal ->
switch (animal) {
case DOG -> "A dog or canine";
case CAT -> "A cat or feline";
case RABBIT -> "A rabbit or bunny";
});
var request = mapping.request("A canine is barking");
var result = request.getSpec().selected("DOG", new ModelProvenance("model", "provider"));
var mapped =
assertInstanceOf(MappedClassificationResult.Selected.class, mapping.map(result));
assertEquals(AnimalKind.DOG, mapped.getValue());
assertNull(result.getConfidence());
}
@Test
void classTokensRemainLocalValues() {
var values = new LinkedHashMap<Category, Class<? extends Animal>>();
values.put(new Category("dog", "A dog or canine"), Dog.class);
values.put(new Category("cat", "A cat or feline"), Cat.class);
values.put(new Category("rabbit", "A rabbit or bunny"), Rabbit.class);
var mapping = new CategoryMapping<>("Which kind of animal is this?", values);
var request = mapping.request("A canine is barking");
var result = request.getSpec().selected("dog", new ModelProvenance("model", "provider"));
var mapped =
assertInstanceOf(MappedClassificationResult.Selected.class, mapping.map(result));
assertSame(Dog.class, mapped.getValue());
}
Use It on Its Own
Depend on a ClassificationService like any other constructor parameter.
Pass one you registered as a bean, or select one by model name with LlmDecisionServiceFactory.classificationService (see ).
Inside a workflow, select one through Ai.classifications() as shown in .
Call classify(input, spec) and switch over the four outcomes:
record TicketRouter(ClassificationService classifier, ClassificationSpec departments) {
String route(String ticketText) {
ClassificationResult team = classifier.classify(ticketText, departments);
return switch (team) {
case ClassificationResult.Selected selected -> selected.getCategoryId();
case ClassificationResult.NoMatch noMatch -> "triage";
case ClassificationResult.Inconclusive inconclusive -> "triage";
case ClassificationResult.Failure failure -> "retry-later";
};
}
}
classify(input, spec) is the same as classify(ClassificationRequest.of(input, spec)).
Providers read the spec’s instructions and categories from the request.
Interpret the Outcome
| Outcome | Meaning |
Selected | One of the supplied category IDs was selected. |
NoMatch | The assessment succeeded, but none of the categories fits. |
Inconclusive | The service could not reach a judgment. |
Failure | An operational failure prevented an assessment; a bounded reason describes the failure. |
ClassificationSpec.selected(...) creates a selection of one of the spec’s categories.
Constructing Selected directly cannot check that the ID belongs to the spec.
ClassificationSpec.validate(result) and CategoryMapping.map(result) throw IllegalArgumentException for an unknown selected ID.
They never turn it into a no-match result.
Mapping preserves no-match, inconclusive, and failure outcomes.
A selection’s optional confidence is reported by the provider, must be finite and between zero and one, and remains absent when unavailable.
It is evidence from that provider, not a guarantee of correctness or calibration across models.
ModelProvenance identifies the actual model and provider, with optional version and request ID.
Assess a Proposition
DecisionService extends ClassificationService and adds assess(PropositionRequest) for a nonblank proposition about input text; the input itself may be empty.
An Answered result contains a boolean answer: both true and false are successful assessments.
Inconclusive and Failure remain distinct from a negative answer.
Optional pTrue is the provider’s probability that the proposition is true, not confidence in whichever boolean answer was returned.
It obeys the same finite zero-to-one validation and absence rules as classification confidence.
This test implementation returns false successfully and leaves pTrue absent:
@Test
void javaServiceImplementation() {
DecisionService service =
new DecisionService() {
public String getName() {
return "model";
}
public String getProvider() {
return "provider";
}
public ClassificationResult classify(ClassificationRequest request) {
return request.getSpec().selected(
request.getCategories().getFirst().getId(),
new ModelProvenance(getName(), getProvider()));
}
public PropositionResult assess(PropositionRequest request) {
return new PropositionResult.Answered(
false, new ModelProvenance(getName(), getProvider()));
}
};
var result =
assertInstanceOf(
PropositionResult.Answered.class,
service.assess(
new PropositionRequest(
"The rabbit is resting", "The animal is a dog")));
assertFalse(result.getAnswer());
assertNull(result.getPTrue());
ClassificationService classifier = service;
assertEquals(ModelType.DECISION, classifier.getType());
assertEquals(ModelType.DECISION, classifier.metadata().getType());
assertEquals(
ModelType.CLASSIFICATION,
ClassificationServiceMetadata.create("model", "provider").getType());
assertEquals(
ModelType.DECISION, DecisionServiceMetadata.create("model", "provider").getType());
}
Store Results as JSON
Classification and proposition results carry their own Jackson annotations, so a plain JsonMapper.builder().build() writes and reads them with no module registered.
Each result is an object whose status member names the outcome:
| Result | status values |
ClassificationResult | selected, no_match, inconclusive, failure |
PropositionResult | answered, inconclusive, failure |
{"status":"selected","categoryId":"billing","confidence":0.91,
"provenance":{"modelName":"example-model","provider":"example-provider"}}
A missing confidence, pTrue, provenance version or requestId is left out rather than written as null.
A failure writes its reason in lower case, such as {"status":"failure","reason":"invalid_response"}.
A Category is {"id":...,"description":...}.
A ClassificationSpec writes decision spec JSON and a ClassificationRequest writes decision request JSON, {"input":...,"spec":...}; see .
Reading throws a Jackson DatabindException for a member these types don’t define, whatever the mapper’s unknown-property setting, so a typo can’t silently drop data.
Describe and Inject a Service
The service interface describes a capability; ModelType describes its model family.
A DecisionService supports classification while continuing to report family DECISION, even through a ClassificationService reference.
A classification-only service reports CLASSIFICATION.
Applications with exhaustive ModelType switches must handle these additional families.
Call metadata() to obtain a pure metadata snapshot before serializing a service description.
Snapshots contain model metadata, not a live provider implementation, client, or credentials.
Serializing arbitrary live service objects is not the snapshot contract.
Use an ordinary constructor parameter in Java or Kotlin to depend on ClassificationService or DecisionService.
In Spring, inject the interface normally.
A prompted service configured under embabel.agent.platform.decisions.llm.services is registered as a decision service bean named after its key, and TypeSafeModelFactory builds Jev services. A decision service also classifies, so it can be injected as a ClassificationService.
Your own implementation registers with an ordinary @Bean.
Observe Calls
The observation decorators live in com.embabel.common.ai.model.observation, separate from the
service contracts. Wrap a service in ObservedClassificationService or ObservedDecisionService,
passing your application’s Micrometer ObservationRegistry after the delegate.
The optional registry defaults to ObservationRegistry.NOOP.
Each call creates one observation: embabel.ai.classification for classify, or embabel.ai.decision for assess, with fixed operation and outcome tags.
A false answer has the same answered outcome as true.
The decorators validate selected IDs and preserve the delegate’s metadata and model family.
Observations and debug logs exclude input, category IDs, model identifiers, and raw exceptions; failures use a fixed error marker without a cause or stack trace.
Exceptions still reach the caller unchanged.
If an observation convention, handler, or exporter throws a non-fatal exception, the decorator logs only the fixed operation and lifecycle phase, makes a best-effort close of any partially opened scope, restores the previous registry scope, and preserves the service result or original provider exception.
Micrometer owns handler-specific scope state, so a handler that also throws from its close callback can prevent its own cleanup callbacks from completing.
Diagnostic logging is also best-effort: a logging backend failure cannot replace the service result or exception.




