Optimization Built for Production.

Eignex builds COMBO, a Kotlin library for tuning agents that have rules they can't break. It picks the model, temperature, prompt style, and tools for each request, learns from how the run scored, and never hands back a configuration that breaks the rules you declared. The engine enforcing those rules, klause, is a full solver in its own right.

View COMBO on GitHub

What COMBO does

Every agent in production carries a pile of settings nobody is quite sure about. Which model to reach for, how hot the temperature should run, whether this request gets the code sandbox. The sensible move is to let the traffic decide, but the moment you do that you need a way to say which combinations are simply off the table.

That's the trade COMBO is built around. You declare a decision space once: the typed variables you want tuned (model, temperature, prompt style, tools) alongside the rules they have to obey.

No top-tier model on the free plan. Code execution only with the sandbox tool next to it. Casual chatter stays under a token cap.

From there, every configuration COMBO hands back satisfies every rule by construction. Nothing is checked after the fact and retried. The illegal combinations never reach the optimizer at all.

COMBO optimization loop A three-step cycle: choose a configuration, serve it to the request, update the optimizer with the observed reward. 01 choose 02 serve 03 update learn, then pick again

What COMBO picks from

Variables and constraints live together in one Kotlin class, so the policy you read is the policy that runs. Each variable carries a type, whether that's a boolean, an integer, a float, a nominal choice, or a multi-select set. Each constraint is an ordinary logical expression over those variables and whatever context the request arrived with.

The compiler hands all of that to klause, the constraint solver underneath, which knows how to sample the configurations that satisfy it. Every choose call returns one of them. If your callers are deployments rather than Kotlin code, the same declaration round-trips through YAML instead.

class AgentPolicy : DecisionSpace() {
    val taskType    by contextNominal("coding", "writing", "research", "casual")
    val userTier    by contextNominal("free", "pro", "enterprise")

    val model       by nominal("haiku", "sonnet", "opus")
    val temperature by floatVar(min = 0.0, max = 1.0, buckets = 16)
    val promptStyle by nominal("terse", "detailed", "chainOfThought")
    val tools       by multiple("web_search", "code_exec", "file_read", "bash")

    val freeTierCantUseOpus by constraint { (userTier eq "free") implies (model ne "opus") }
    val codeExecNeedsBash   by constraint { tools.contains("code_exec") implies tools.contains("bash") }
    val casualKeepsItCheap  by constraint { (taskType eq "casual") implies (model ne "opus") }
}
name: AgentPolicy

context:
  taskType: { type: nominal, labels: [coding, writing, research, casual] }
  userTier: { type: nominal, labels: [free, pro, enterprise] }

variables:
  model:       { type: nominal,  labels: [haiku, sonnet, opus] }
  temperature: { type: float,    min: 0.0, max: 1.0, buckets: 16 }
  promptStyle: { type: nominal,  labels: [terse, detailed, chainOfThought] }
  tools:       { type: multiple, labels: [web_search, code_exec, file_read, bash] }

constraints:
  freeTierCantUseOpus:
    type: imp
    left:  { type: nomeq, name: userTier, label: free }
    right: { type: not, child: { type: nomeq, name: model, label: opus } }

  codeExecNeedsBash:
    type: imp
    left:  { type: ref, name: tools.code_exec }
    right: { type: ref, name: tools.bash }

  casualKeepsItCheap:
    type: imp
    left:  { type: nomeq, name: taskType, label: casual }
    right: { type: not, child: { type: nomeq, name: model, label: opus } }

Handing the choice to the request

One decision space covers two fairly different regimes. Online, for high-volume traffic where every request is a fresh draw and what matters is cumulative reward across millions of decisions. Offline, for expensive sweeps where a single evaluation costs minutes or real money and the whole budget is tens to hundreds of trials. Same schema, same rules, a different optimizer plugged in behind the loop.

The call shape doesn't change between them. Ask for a configuration, hand it to the request that needed it, then report back how it went. A null return is the rare honest answer that nothing satisfies the rules, never a quiet fallback to some default.

val choice = combo.choose() ?: return  // null only if no feasible config exists
serve(choice)                          // your code: run the request with this configuration
combo.update(choice, observe())        // score the run, learn for next time
# null exit only if no feasible config exists
choice=$(curl -fsS -X POST "$COMBO_URL/v1/choose" \
  -H 'content-type: application/json' \
  -d '{"context": {"taskType": "coding", "userTier": "pro"}}') || exit

# your code: run the request with this configuration
serve "$choice"

# score the run, learn for next time
curl -fsS -X POST "$COMBO_URL/v1/update" \
  -H 'content-type: application/json' \
  -d "{\"choice\": $choice, \"reward\": $(observe)}"

Learning from each result

Every outcome you report goes straight back into the model. Updates are safe from many threads at once, with the locking strategy picked per statistic so the hot path stays hot. Choose returns in milliseconds, embedded or over HTTP.

Scaling out doesn't need a coordinator. Each pod learns from the traffic it sees and ships what it learned to the others, so you can run as many copies as the load asks for and still land on the answer a single instance would have reached. Nothing central sits on the request path.

Where it runs

There are two ways to run it. Embed the library straight into your service, or deploy COMBO as its own pod and call into it over HTTP. Same decision space, same loop, same guarantees either way.

Embedding is not a JVM-only story. COMBO is Kotlin multiplatform, so the same code compiles to a native binary for Linux, macOS, and Windows as well as to the JVM, JS, and Wasm. A Python or Go service loads it in-process and calls straight into the solver, the same way a Kotlin one does, with no network hop between your request handler and the decision it needs.

Read the README All projects on GitHub

Klause, the solver underneath

Hand klause a set of rules and it finds the combinations that satisfy them, or tells you for certain that none exist. That sounds easy until the rules start interacting. A few dozen settings with rules between them have more combinations than you could ever try one by one, and the valid ones can be vanishingly rare.

It handles on/off switches, whole numbers with fixed ranges, and continuous quantities in a single model, and it can rank what it finds against a goal instead of handing back the first thing that fits. It can also produce many different valid answers rather than one, which is what makes it useful for generating test data or exploring a design space. Underneath sit several solving engines and a portfolio that picks between them per problem, covered properly in the docs.

Nothing about it requires COMBO. Point the command line at a problem file and it solves it, reading the standard formats (MiniZinc, XCSP3, SMT-LIB, MPS, OPB, DIMACS) as either a native binary or on the JVM. Declaring the model in Kotlin and calling it directly is the other route, and that's the one COMBO takes with the rules from your decision space.

Order.kt
class Order : VariableSchema() {
    val a by intVar(min = 1, max = 3)
    val b by intVar(min = 1, max = 3)
    val c by intVar(min = 1, max = 3)
    val unique by constraint { allDifferent(a, b, c) }
}

val schema = Order()
val compiled = schema.compile()
for (sample in BacktrackSolver(compiled).enumerate().take(5)) {
    println(compiled.decode(schema.a, sample))
}

Klause docs Klause on GitHub

Recent Posts

Agentic Coding Has No Floor

Opinions Rasmus Ros 9 min read

Vibe coding is what agentic coding decays into when you're tired or four hours in. The structural fix has to come from the harness vendors, not from another instruction file.

One Shape Across the Eignex Stack

Updates Rasmus Ros 4 min read

Three months into the Eignex rewrite, the libraries finally share one config shape that doubles as a YAML wire format. A checkpoint on what changed in each repo.

From Stringly to Strongly Typed

Engineering Rasmus Ros 7 min read

Three attempts at typed schemas in Kotlin: an imperative builder, a type-encoded product, and the property-delegate design I ended up shipping as skema.

Writing the Loss Function

Opinions Rasmus Ros 7 min read

AI plus a feed isn't a new medium, it's the same engagement loop with cheaper supply. The objective the loop optimizes for is a choice, not a law of physics.

KEncode: Packing Data for Strict Limits

Engineering Rasmus Ros 14 min read

Sometimes 80 characters of URL is all you get, and JSON won't survive the trip. kencode squeezes structured state through it, with the schema written as a plain Kotlin data class.

Engine Building and Status Updates

Updates Rasmus Ros 2 min read

What I'm actually trying to build: a continuous optimizer that learns from a live stream, fits a probabilistic model, and leans on an SMT solver to stay inside hard constraints.

Building Eignex in the Open

Updates Rasmus Ros 2 min read

A quick who-am-I and what-is-this. PhD in continuous optimization, left academia, now building Eignex in the open.

View All Posts →