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.
Conditions
Section titled “Conditions”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.
Answers
Section titled “Answers”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.
With when
Section titled “With when”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.
From Python
Section titled “From Python”.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)When to use rules
Section titled “When to use rules”- 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.

