cascade()
Ask cheaply first. Escalate only when Jev isn't confident, then fall back to anything.
Basics#
Most inputs are easy. A cascade asks the cheap question first and only climbs to a more expensive one when Jev isn't confident enough. When no rung is sure, it hands off to a fallback, which can be any node at all: an LLM, a human queue, a coin.
import { cascade, tier, choice, step } from "jevchain";
const verdict = choice("Should the recipient reply to this text message?", {
reply: "Replying is kind, safe and likely to lead somewhere good.",
"leave-on-read": "Replying would restart something unhealthy, or the message doesn't need a reply.",
});
const decide = cascade("should-i-reply", {
tiers: [
// Cheap: only the message itself.
tier("gut-check", { ask: verdict, minConfidence: 0.7, state: "{{input.message}}" }),
// Thorough: the whole input, history and receipts included.
tier("full-context", { ask: verdict, minConfidence: 0.5 }),
],
// Nobody was sure. Hand off to something that isn't Jev.
fallback: step("ask-the-group-chat", () => "Screenshot it and send it to the group chat."),
});Tiers run in order, one Jev call each. The first tier whose confidence clears its minConfidence answers, and the rest never run. So on easy inputs you pay for one small call; on hard ones you pay for exactly as much thinking as it took.
{"message":"u up?","context":{"theirLastMessage":"5 weeks ago","timesTheyCancelledPlans":4,"howIFeel":"I finally stopped checking my phone"}}
Tiers#
Build each rung with tier(id, config). A tier asks exactly one question of any type, and its confidence is confidenceOf(answer): Jev's own confidence for choice and score, distance from a coin flip for noul. It accepts when that number is at least minConfidence; otherwise it escalates.
| name | type | default | what it does |
|---|---|---|---|
| id | string | Names the rung in the result and the trace. "fallback" is reserved. | |
| ask | Question | The question this rung asks. Rungs can ask the same question or different ones. | |
| minConfidence | number (0–1) | Accept this rung's answer at or above this confidence. | |
| state | string | (input) => Entry | default the input | What this rung shows Jev. The trick of a good cascade: give early rungs less (a template like "{{input.message}}"), later rungs more. |
| model | string | default client's | Pin a model for this rung, e.g. jev-1.13.0. |
| title | string | Label for UIs. |
The fallback#
The fallback is a node. It runs with the cascade's input (not the tiers' answers) and its output lands in the result. A step is the usual choice, because that's where your code, and your more expensive model, lives:
const decide = cascade("refund-policy", {
tiers: [tier("quick", { ask: isRefundable, minConfidence: 0.8 })],
fallback: step("ask-an-llm", async (ticket: Ticket, ctx) => {
ctx.log("escalating to the expensive model");
return llm.complete({ prompt: render(ticket), signal: ctx.signal });
}, { timeoutMs: _000, retries: 1 }),
});It could just as well be an emit (“a human will get back to you”), a route or a whole chain. In the graph, tiers are drawn as a ladder: dotted escalate edges climb from rung to rung and finally to the fallback.
Reading the result#
A cascade outputs a tagged union, so you can't read an answer without first checking who gave it:
type CascadeResult<Tiers, F> =
| { resolvedBy: "tier"; tier: string; answer: AnswerOf<Tiers[number]["ask"]> }
| { resolvedBy: "fallback"; output: F };const next = step("verdict", (r: OutputOf<typeof decide>) => {
if (r.resolvedBy === "fallback") return r.output; // whatever the fallback returned
// r.tier: which rung answered ("gut-check" | "full-context", as a string)
// r.answer: the typed answer, ChoiceAnswer<"reply" | "leave-on-read">
return r.answer.choice === "reply" ? "Reply." : "Leave them on read.";
});The decision in the trace records the climb:
metricis"confidence", and every tier is an edge whosevalueis the confidence it reached (nullfor rungs that never ran).takenis the tier id that answered, or"fallback".summaryspells it out, e.g. Escalated past "gut-check" (0.41); "full-context" answered at 0.78 confidence (needed 0.50).