Repository navigation
noul
noul() is the yes/no judge. Exactly one proposition on trial: does this message want a human, does this resume mention distributed systems, does this text read as urgent. It comes back with one number, the probability that the answer is yes, and nothing else. For a two outcome judgment that single number is the complete answer.
It runs on the same single forward pass as choice and score, answering with one of two letters, so a judgment is as cheap as a decision. The mechanics are in Under the hood.
Use noul() when exactly one proposition is on trial. If your decision depends on several conditions, run several Nouls and combine the numbers in your own code: it keeps each judgment clean and every probability independently reusable.
For reference where the other two fit: a fixed set of options with no order between them belongs to choice, a position on a describable spectrum belongs to score, a single yes/no proposition belongs here.
import { noul } from "smart-decisions";
const model = {
apiBaseUrl: "http://localhost:8000/v1",
apiKey: "a-super-secret-api-key",
model: "/models/Qwen3.5-4B-Q4_K_M.gguf",
};
const answer = await noul({
model,
state:
"I have asked three times now. Can I please just talk to a real person?",
instructions: "Is the customer asking for a human agent?",
criteria: {
true: "Explicitly asks for a person, agent or human",
false: "No sign of wanting a person",
},
});
console.log(answer);The answer:
{
noul: 0.99; // the probability the answer is "yes" — 0 = no, 1 = yes, 0.5 = it split itself
}The fields about the question are the usual ones. state is the situation, instructions is the proposition to judge, and criteria is optional: an object where you define what yes and no mean. Here the instruction plus the state already make the boundary obvious, but writing the two sides out removes any ambiguity about what counts.
On the answer side there is deliberately no extra confidence field. With only two outcomes, the single probability describes the judgment completely: a value near 0.5 is the no lean signal in itself.
Pick your thresholds out of what a wrong answer costs on each side. A support tool that dares not send people to a human who is not needed, but really cannot leave a human ask unserved, might read:
const answer = await noul({
model,
state: message,
instructions: "Does the customer ask for a human agent?",
});
if (answer.noul > 0.8) {
escalateToHuman(message); // almost certainly wants a person
} else if (answer.noul < 0.2) {
stayAutomated(message); // clearly no
} else {
flagForReview(message); // split; do not let either path act on it
}The 0.8 and 0.2 in that snippet are examples, not a recommendation. What matters is keeping the near 0.5 band alive as its own third path rather than letting either branch swallow it: a reading nearest 0.5 is the model telling you it has no clear answer, and acting on unclear readings is what the confidence exists to prevent.
When several judgments feed the same decision, chain them explicitly:
const wantsHuman = await noul({ ... });
const isUrgent = await noul({ ... });
if (wantsHuman.noul > 0.8 || isUrgent.noul > 0.8) {
...
}Each noul judged one condition; the combination logic stays in your hands.
criteria is optional: a clear question answers well without it. Use it when the meaning of yes is a judgment call a competent human would want defined: a word with domain specific meaning, an implicit standard, or when the model's definition of "yes" is likely to differ from your team's. Each side takes its own one line description:
const answer = await noul({
model,
state: msg,
instructions: "Does the customer mention having tried something already?",
criteria: {
true: "Mentions a prior attempt, ticket, or that they have asked before",
false: "No sign of any previous contact",
},
});Either side is optional on its own: fill just true if only the yes side is slippery, or just false if the no side is.
The transport knobs are the same as on choice: mode, maxRetries, timeoutMs and, on the model object, extraBody. A batch style call where latency is unimportant but throughput of unattended runs is:
import { noul } from "smart-decisions";
const answer = await noul({
model,
state:
"This is the fourth ticket from the same customer mentioning a refund that never arrived.",
instructions:
"Is this customer complaining about a refund they did not receive?",
maxRetries: 4,
timeoutMs: 60_000,
});At best you can read noul 0.9 here, and your retention logic decides what to do with it.
- A response with no logprobs throws instead of inventing a number.
- In System 2 mode, a reply that keeps failing schema validation past the retry budget throws with the last rejection reason.
choice and score for non binary questions, Auto mode for when a split verdict escalates to a deliberate one, Under the hood for how the two letters are read into one number.
Documentation
Examples
Reference