Skip to content

Instant decisions with rules

Many decisions have easy cases you already know the answer to: a ticket that mentions an invoice goes to billing, an enterprise customer is always a priority. Give a question rules and those cases are answered on the spot, with no model call. Everything else still goes to the model.

{
"state": {"ticket": "Where is my INVOICE for March?", "plan": "enterprise", "open_tickets": 4},
"questions": {
"team": {
"type": "choice",
"instructions": "Which team should handle this?",
"options": {"billing": "", "technical": "", "sales": ""},
"rules": [
{"if": {"ticket": {"contains": "invoice"}}, "answer": "billing"},
{"if": {"ticket": {"starts_with": "Error"}}, "answer": "technical"}
]
},
"priority": {
"type": "noul",
"instructions": "This ticket needs a reply today",
"rules": [{"if": {"plan": ["enterprise", "premium"], "open_tickets": {"gte": 3}}, "answer": true}]
},
"tone": {"type": "score", "instructions": "How upset is the customer?", "levels": ["calm", "annoyed", "angry"]}
}
}

Here team and priority are answered by rules, so only tone is sent to the model. If every question is answered by a rule (or skipped by its when), no model is called at all: the decision takes about no time and costs $0.

if holds conditions on top-level fields of the state, which must be a JSON object (otherwise the request gets 422). Every condition in one if must hold.

Condition Matches when the field
"billing", 3, true equals the value
["billing", "sales"] equals any of the values
{"contains": "refund"} is a string containing the text, ignoring case
{"starts_with": "Error"} is a string starting with the text (case matters)
{"gt": 2}, {"gte": 2}, {"lt": 2}, {"lte": 2} is a number >, ≥, <, ≤ the value
{"exists": true} / {"exists": false} is present and not null / is missing or null

Several operators in one object must all hold: {"gte": 1, "lt": 5} is a range. A missing field, or a field of the wrong type ({"gt": 2} on a string), never matches, except {"exists": false}. Unknown operators get 422, as do operator arguments of the wrong type.

answer is written like a feedback label: an option key for a Choice, a level index for a Score, true/false for a Noul, a list of option keys for a Multi, and the value itself for a Text, Number or Integer. A label that isn’t an answer to the question gets 422. Up to 32 rules per question; they are tried in order and the first match wins.

A rule’s answer puts all its probability on that label, and says which rule gave it:

"team": {"choice": "billing", "probabilities": {"billing": 1.0, "technical": 0.0, "sales": 0.0, "none_of_these": 0.0},
"confidence": 1.0, "calibrated": false, "rule": 0}

Rule answers are written to the audit log as returned, so you can see why a decision was made. They are not model output, so they are not stored for calibration and feedback for them gets 404.

when and rules combine. when is checked first: a question whose when fails is {"skipped": true} even if a rule would match. See Conditional questions.

.rule(answer, **conditions) adds a rule, and can be chained like .when(...):

from curva import Choice, Noul, Score
questions = {
"team": Choice("Which team should handle this?", ["billing", "technical", "sales"])
.rule("billing", ticket={"contains": "invoice"})
.rule("technical", ticket={"starts_with": "Error"}),
"priority": Noul("This ticket needs a reply today")
.rule(True, plan=["enterprise", "premium"], open_tickets={"gte": 3}),
"tone": Score("How upset is the customer?", ["calm", "annoyed", "angry"]),
}
d = client.decide(ticket, questions)
d["team"].choice, d["team"].rule # "billing", 0 (None when the model answered)
  • Hard business rules: things that must always happen, like routing by plan or country.
  • Zero-cost easy cases: a keyword that settles it, so the model only sees the hard ones.
  • Start with rules, then hand over: calibrate the model on the cases rules don’t cover, and drop a rule once the model agrees with it.

Keep rules to what you’d write in code anyway. Anything that needs reading between the lines is what the model is for.

© 2026 Tarkova Private Limited.