Type-safe LLM decisions for Kotlin.
Most LLM classification code eventually becomes if (modelSaysYes) ....
But what happens when the model isn't sure?
Kleene gives you TRUE, FALSE, and UNKNOWN — three outcomes your code must handle:
val urgent by ai.feels("Needs a response today")
when (urgent(message).truth) {
TRUE -> flag(message)
FALSE -> queue(message)
UNKNOWN -> humanReview(message)
}The model judges. Your code decides.
You set how much evidence is enough to act. UNKNOWN means the answer did not meet that bar; it is a value you can route to human review. Timeouts and malformed responses throw errors.
ScriptedJudge supplies fixed probabilities so you can try all three outcomes without a model,
an account, or a server. This complete example uses the same feels and when as your application:
import kleene.*
import kleene.Truth.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
for (p in listOf(0.95, 0.05, 0.55)) {
val ai = Kleene(ScriptedJudge { feels("urgent", p) })
val urgent by ai.feels("Needs a response today")
when (urgent("Can you look at this?").truth) {
TRUE -> println("TRUE -> flag")
FALSE -> println("FALSE -> queue")
UNKNOWN -> println("UNKNOWN -> human review")
}
}
}With the default thresholds, 0.95 becomes TRUE, 0.05 becomes FALSE, and 0.55 becomes UNKNOWN.
The probabilities here are scripted: changing the message does not change the answer.
Run the example with JDK 17 and Maven:
git clone https://github.com/keyboardsamurai/kleene.git
cd kleene
mvn -q -DskipTests install
mvn -q -pl demo exec:java -Dexec.mainClass=kleene.demo.quickstart.MainKtThe first build downloads dependencies and may take longer than 30 seconds. The example itself makes no network calls and prints:
TRUE -> flag
FALSE -> queue
UNKNOWN -> human review
Ready to use it in your project? Install from Maven Central, then connect a real Judge. For repeatable tests with recorded answers, use ReplayJudge.
enum class Queue(val label: String) { BILLING("billing"), TECH("technical support"), SALES("sales") }
val route by ai.choose<Queue>("Which team should handle this ticket?") { it.label }
suspend fun dispatch(ticket: String) {
val queue = route(ticket).orElse { return humanReview(ticket, it.reason) } // "top p=0.62 < acceptAt=0.85"
send(ticket, queue)
}The options are your enum constants, so a when over queue must handle every team. orElse is inline,
which lets return leave dispatch early.
val answers = ai.ask(ticket, listOf(route, urgent, angry)) // one Judge call for all three
answers[angry].truth // UNKNOWN: p(true)=0.55 is inside the default band
answers[angry].at(0.51).truth // TRUE: the same answer under a looser Policy, zero model calls
Evidence.feels(storedP, "jev", "jev-1.13.0").decide(Policy(acceptAt = 0.95)) // replay a p(true) from your DBKleene keeps the probabilities, so you can reapply a different Policy next week without paying again.
val uploadError by contract {
rule("under 300 chars") { it.length <= 300 }
+"Says the upload failed"
+"Tells the user to retry"
+"Does not blame the user"
}
println(ai.check(reply, uploadError, source = ticket))contract "uploadError": FAIL (judge scripted, model scripted)
PASS under 300 chars
PASS Says the upload failed p(true)=0.97
FAIL Tells the user to retry p(true)=0.05
UNKNOWN Does not blame the user p(true)=0.6
Rules run in plain Kotlin. All the requirements go to the Judge in one ask. report.assertPassed() turns the
check into a test assertion, and report.toJson() gives your CI a file to keep.
// Once, against the real Judge: each ask is appended to the file.
val ai = Kleene(SystemOneJudge.fromEnv().recordingTo(Path("src/test/resources/tickets.jsonl")))
// Every build after that: the same answers, with no network and no API key.
val ai = Kleene(ReplayJudge(Path("src/test/resources/tickets.jsonl")))Replay is strict. If you change a question or an input, it throws IllegalStateException. It never falls through
to a live model, and it never makes up an answer.
Not sure which acceptAt to use? Measure it on your own labeled data with
Sweep.
The usual way to put a model behind an if is to prompt a chat model for {"answer": true, "confidence": 0.95}
and keep the Boolean. The confidence is self-reported text, doubt turns into false, and a failed call often turns
into a default value. Kleene keeps the model's probabilities, keeps doubt as UNKNOWN, and keeps failures as
exceptions. It also gives you:
choosewith your own enum or class, andscorewith the whole distribution over your rubric.- A stricter or looser Policy on existing answers, with zero model calls.
check: test any output against plain-language requirements, with PASS, FAIL or UNKNOWN for each one.- The same code against TypeSafe's cloud or a local Judge, chosen by three environment variables.
The name comes from Stephen Kleene's three-valued logic: TRUE and UNKNOWN is UNKNOWN, FALSE and UNKNOWN is FALSE.
Add the dependency from Maven Central (JDK 17, Kotlin 2.x):
<dependency>
<groupId>com.antonioagudo.libs</groupId>
<artifactId>kleene</artifactId>
<version>0.2.0</version>
</dependency>Runtime dependencies are Kotlin's standard library, kotlinx-coroutines-core, and
kotlinx-serialization-json.
Set KLEENE_MODEL (for example jev-1.13.0) and KLEENE_API_KEY, or point KLEENE_BASE_URL at a local Judge.
Start with the guide. It goes from the first judgment to advanced features:
- Getting started: install, configure a Judge, first
feelsquestion - Questions:
feels,choose,score,Verdict,Rating,Truth - Asking several questions: one ask, one model call,
Answers - Policy and Evidence: Policy values, UNKNOWN, reapply with zero model calls
- Judges: cloud or local, timeouts, retries, errors
- Checking outputs:
Contract,check,Report - Testing:
ScriptedJudge, record and replay - Picking a Policy with Sweep: pick an
acceptAtfrom labeled Evidence
- Specification: the normative API and semantics
- Architecture decision records
- Glossary
mvn test # unit tests; live excluded
KLEENE_BASE_URL=http://127.0.0.1:8009 KLEENE_MODEL=kev-4b mvn test -Dgroups=live # live smoke testsStart a local Judge only with scripts/kev.sh or scripts/laya.sh, one at a time; see
Run a local Judge safely.
The unpublished demo module (ADR-0005) includes the no-key quick start and three larger programs:
- Promise tests:
checka Contract of user promises over every git version of a real terms-of-service document, and reapply a Policy at zero model calls. - Kalah bench: scores Judges against each other on Kalah positions, with an engine as the truth.
- ICD bench: codes 100 synthetic multilingual clinical documents into ICD-10-CM categories and shows where UNKNOWN saves a wrong code.
Kleene is inspired by Probably
(source) by Steve Faulkner,
a small programming language for LLM workflows powered by Jev. Probably showed judgments as ordinary control
flow: feels, match and an explicit "maybe" branch. Kleene carries that programming model into plain Kotlin:
feels, choose and UNKNOWN. Thank you, Steve.
MIT. See LICENSE.
