The examples on this page use Northpine, a fictional outdoor-gear shop whose
refund-exception decision grants, escalates or denies refund requests outside its standard return policy. How Moonbounce works introduces it.refund-exception spec: five yes/no features, a combine that weighs them, and a calibrate on top. This page follows real numbers through it: one ticket from its answers to grant, then two more, the alternatives to the default arithmetic, and what happens when an answer is missing.
A feature’s value is P(yes)
For each boolean feature, the engine returns a number between 0 and 1: its probability that the answer to the prompt is yes, written P(yes). This is the feature’svalue in the response. 0.96 means “almost certainly yes”, 0.03 “almost certainly no”, and 0.5 means the engine can’t tell.
Ticket A is a sleeping bag whose zipper ripped out on the first night: a $129 order, 42 days after delivery, from a customer with no recent refunds. Its answers:
These are illustrative engine answers; the arithmetic below is exactly what the API does with them.
The default combine: an average
Until weights are learned, a combine scores with its default weights. By default (the"mean" prior) the score is the average of the answers, after flipping every lowers answer: a lowers answer x counts as 1 − x. Flipping makes every term point the same way. For outside_window, 0.98 says the request is almost certainly late, so 0.02 is how much that answer argues for granting.
The five flipped answers sum to 3.83, and 3.83 / 5 = 0.766. That is
grant_score.
The same score as weights and a bias
The API doesn’t store “flip, then average”. It stores the same arithmetic as one weight per term and a bias (a constant added to the sum), and reads the raw answers:- a
raisesterm has weight +1/n, alowersterm −1/n, where n is the number of terms; - the bias is the number of
lowersterms divided by n.
lowers terms: every lowers weight is −0.2, defective’s is +0.2, and the bias is 0.8.
The response reports each term’s contribution, its weight times its raw answer. The bias is not among them, so the score is the bias plus the sum of the contributions:
The contributions sum to −0.034, and 0.8 − 0.034 = 0.766. In the response:
outside_window pulled it down by 0.196 and defective pushed it up by 0.192, while the three answers near zero barely moved it. A lowers term contributes close to nothing when its answer is no, because the bias already assumed it would be.
The API never rounds a score or a contribution. -0.020000000000000004 is the exact floating-point result of −0.2 × 0.1. Round only for display: a score shown as 0.38 might sit just below a 0.38 cut.
Three tickets side by side
Two more Northpine tickets go through the same arithmetic:- B: a $899 kayak, 61 days after delivery; the customer changed their mind.
- C: a $189 tent that arrived two weeks late because of the carrier, used once on the trip it was for, now 39 days after delivery.
B’s flipped answers are 0.01, 0.80, 0.02, 0.97 and 0.01, averaging 0.362 (the response reads
0.36200000000000004). Nothing argues for it. C’s are 0.05, 0.65, 0.05, 0.98 and 0.97, averaging 0.54: no defect, a little use, and late. Northpine’s owner says C should be granted, because the carrier delay ate the return window. No feature in the spec captures that, which is why C lands in the middle.
When one answer should be enough: prior: "any"
An average dilutes. In a five-term mean, one term can move the score by at most 1/5. That suits Northpine, where every reason should be weighed against the others, but not a decision where any single reason is enough.
Say Northpine also checks whether a ticket needs a person at all, with three features where any one yes should send it to someone:
legal_threat 0.02, safety_issue 0.97, public_complaint 0.03. The mean is 0.34, which looks like a ticket nobody needs to see.
Set "prior": "any" on the combine and its default weights change: every raises term gets +8, every lowers term −8, the bias is −4, and the sum goes through a logistic curve, 1 / (1 + e^−s). The curve squeezes any number onto 0 to 1: a sum of 0 maps to 0.5, +4 to about 0.98 and −4 to about 0.02. For the stove:
- sum: −4 + 8 × (0.02 + 0.97 + 0.03) = 4.16
- score: 1 / (1 + e^−4.16) = 0.9846
any is a sum, not a maximum, so answers add up:
Two weak maybes reach 0.723, and three unsure answers score higher than one certain one. A strong
lowers answer cancels a strong raises one. any needs at least one raises term. Writing "prior": "mean" is the same as leaving prior out; "any" changes the structure hash. Either prior only sets the default weights: any other weights document replaces it.
Numbers fixed in code
When you know the numbers you want, write them into the combine asfixed:
linkisidentity(the score is the bias plus the contributions, clamped to 0 to 1) orlogistic(the curve above).weightshas one number per term, and its sign must match the term’seffect: zero or more forraises, zero or less forlowers.biasis any finite number.
prior: "any". With these numbers ticket A scores 0.858, B 0.364 and C 0.448.
Calibrate
grant is a calibrate node, and today it passes its input through: A’s grant is 0.766 with "calibrated": false. Once a calibration has been fitted from feedback, it will map the score onto the probability that the target (“The refund exception should be granted.”) is true, and calibrated will be true. Fitting is not available yet.
version.weights_fitted
Every response has a version object. version.weights_fitted is true only when the weights in use were fitted on feedback. Default weights, whether sent as "weights": "default" or pinned by their hash, and weights carried over by a suggestion are not fitted. Today every call reports false. fixed combines and per-node calibrated are reported on the nodes, not here.
Cut points: from a score to an outcome
You turn the score into an outcome, the name of what your app should do, with cut points sent in each request’soutcomes:
belowis the outcome under the first cut.cutsholds 1 to 9 cuts, withatvalues strictly ascending.- Outcome names follow the node-name rules (lowercase slugs), and
belowplus the cut outcomes are all distinct.
at is at or below the score, or below if there is none. A score exactly at a cut takes that cut’s outcome: 0.38 is escalate, and 0.60 is grant. A request that breaks a rule is a 400 with param outcomes.cuts.
The three Northpine tickets on the scale:
Status and abstain
Every response has astatus:
scored: there is a score and your cut points turned it into an outcome.no_outcomes: there is a score, but the request sent nooutcomes, so there is nooutcome. Cut the score in your own code, or treat the call as observe-only.abstained: the output has no answer, so there is no score.outcomeisoutcomes.abstainwhen you set it, and absent otherwise.
abstain names the outcome for the case where there is nothing to cut, and it may reuse a name on the scale. Northpine could send "abstain": "escalate" so that an unanswerable ticket goes to a person. That is why you tell an abstention apart by status, never by the outcome name: escalate alone doesn’t say whether the score landed between the cuts or there was no score.
When a feature has no answer
A feature can come back without a value in two ways, and they lead to different results:- Abstained: the engine ran and declined to answer. The abstention passes to every node that reads the feature: with default weights a combine reads all its terms, so one abstained feature makes
grant_scoreand thengrantabstain. The call succeeds with statusabstained, as above. - Failed: the engine call itself failed. If the score depends on the feature, the output fails too. The call is a 502
ENGINE_FAILED,details.featureslists the failed features, and nothing is stored. When every failure that sank the output was temporary, such as a rate limit, the call is a 503ENGINE_UNAVAILABLEwithRetry-Afterinstead: retry it with the sameIdempotency-Key.
carrier_delay feature without putting it in grant_score’s terms and it failed, the call would still answer 200 with A’s score of 0.766 and grant, show carrier_delay as {"type": "boolean", "failed": true, ...}, and add a warning:
Choosing cut points
Cut points live in your code, next to the spec. They are not part of either hash, they travel with every call and the response echoes them back inoutcomes, so your logs and Moonbounce’s records agree on which cuts produced an outcome. Changing them never creates a new version, and a preview replays a spec at the cut points you send, so you can try new ones against past cases before you ship them.
Middle outcomes like escalate are a capacity choice. Moving Northpine’s grant cut from 0.60 up to 0.80 sends ticket A (0.766) to a person instead of granting it. Make the middle band as wide as the number of tickets your people can take, and narrow it as the decision earns trust.