> ## Documentation Index
> Fetch the complete documentation index at: https://docs.moonbounce.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# From answers to an outcome

> How feature answers become a score, and how your cut points turn that score into an outcome.

> [!NOTE]
>
> 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](https://docs.moonbounce.ai/learn/how-it-works.md#the-running-example) introduces it.

[Describe a decision](https://docs.moonbounce.ai/learn/decisions.md) built Northpine's `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's `value` 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:

| Feature | Answer |
| - | - |
| `outside_window` | 0.98 |
| `item_used` | 0.10 |
| `defective` | 0.96 |
| `repeat_refunder` | 0.03 |
| `high_value` | 0.02 |

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.

| Feature | Effect | Answer | Counts as |
| - | - | - | - |
| `outside_window` | lowers | 0.98 | 0.02 |
| `item_used` | lowers | 0.10 | 0.90 |
| `defective` | raises | 0.96 | 0.96 |
| `repeat_refunder` | lowers | 0.03 | 0.97 |
| `high_value` | lowers | 0.02 | 0.98 |

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 `raises` term has weight +1/n, a `lowers` term −1/n, where n is the number of terms;
* the bias is the number of `lowers` terms divided by n.

Each flipped answer (1 − x) / n is 1/n − x/n, and the 1/n parts collect into the bias. For Northpine, n = 5 with four `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:

| Feature | Weight | Answer | Contribution |
| - | - | - | - |
| `outside_window` | −0.2 | 0.98 | −0.196 |
| `item_used` | −0.2 | 0.10 | −0.020 |
| `defective` | +0.2 | 0.96 | +0.192 |
| `repeat_refunder` | −0.2 | 0.03 | −0.006 |
| `high_value` | −0.2 | 0.02 | −0.004 |

The contributions sum to −0.034, and 0.8 − 0.034 = 0.766. In the response:

```json theme={null}
{
  "status": "scored",
  "score": 0.766,
  "outcome": "grant",
  "nodes": {
    "outside_window": { "type": "boolean", "value": 0.98 },
    "item_used": { "type": "boolean", "value": 0.1 },
    "defective": { "type": "boolean", "value": 0.96 },
    "repeat_refunder": { "type": "boolean", "value": 0.03 },
    "high_value": { "type": "boolean", "value": 0.02 },
    "grant_score": {
      "type": "combine",
      "value": 0.766,
      "contributions": {
        "outside_window": -0.196,
        "item_used": -0.020000000000000004,
        "defective": 0.192,
        "repeat_refunder": -0.006,
        "high_value": -0.004
      }
    },
    "grant": { "type": "calibrate", "value": 0.766, "calibrated": false }
  }
}
```

Read contributions as the "why" of a score: `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.

| Ticket | `outside_window` | `item_used` | `defective` | `repeat_refunder` | `high_value` | Score |
| - | - | - | - | - | - | - |
| A | 0.98 | 0.10 | 0.96 | 0.03 | 0.02 | 0.766 |
| B | 0.99 | 0.20 | 0.02 | 0.03 | 0.99 | 0.362 |
| C | 0.95 | 0.35 | 0.05 | 0.02 | 0.03 | 0.540 |

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:

```json theme={null}
"needs_person": {
  "type": "combine",
  "terms": [{ "node": "legal_threat" }, { "node": "safety_issue" }, { "node": "public_complaint" }]
}
```

A customer reports a camp stove that caught fire: `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

One confident yes is now enough. With the logistic curve the score is no longer the bias plus the contributions: it is the curve applied to that sum. The contributions here are 0.16, 7.76 and 0.24.

`any` is a sum, not a maximum, so answers add up:

| Answers (`legal_threat`, `safety_issue`, `public_complaint`) | Mean | `any` |
| - | - | - |
| 0.02, 0.97, 0.03 | 0.34 | 0.985 |
| 0.02, 0.03, 0.01 | 0.02 | 0.029 |
| 0.30, 0.02, 0.30 | 0.207 | 0.723 |
| 0.40, 0.40, 0.40 | 0.40 | 0.996 |

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 as `fixed`:

```json theme={null}
"grant_score": {
  "type": "combine",
  "terms": [
    { "node": "outside_window", "effect": "lowers" },
    { "node": "item_used", "effect": "lowers" },
    { "node": "defective" },
    { "node": "repeat_refunder", "effect": "lowers" },
    { "node": "high_value", "effect": "lowers" }
  ],
  "fixed": {
    "link": "identity",
    "weights": { "outside_window": -0.1, "item_used": -0.2, "defective": 0.4, "repeat_refunder": -0.2, "high_value": -0.1 },
    "bias": 0.6
  }
}
```

* `link` is `identity` (the score is the bias plus the contributions, clamped to 0 to 1) or `logistic` (the curve above).
* `weights` has one number per term, and its sign must match the term's `effect`: zero or more for `raises`, zero or less for `lowers`.
* `bias` is any finite number.

Fixed numbers are used ahead of any weights document, the defaults or a pinned hash: Moonbounce's weights never override them. They are part of the spec, so changing one creates a new version, and they can't be combined with `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's `outcomes`:

```json theme={null}
"outcomes": {
  "below": "deny",
  "cuts": [
    { "at": 0.38, "outcome": "escalate" },
    { "at": 0.6, "outcome": "grant" }
  ]
}
```

* `below` is the outcome under the first cut.
* `cuts` holds 1 to 9 cuts, with `at` values strictly ascending.
* Outcome names follow the node-name rules (lowercase slugs), and `below` plus the cut outcomes are all distinct.

The outcome is the last cut whose `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:

```text theme={null}
0 ────── deny ──────┼─── escalate ───┼────── grant ────── 1
                   0.38             0.60
                ▲             ▲               ▲
             B 0.362       C 0.540         A 0.766
```

| Score range | Outcome | Ticket |
| - | - | - |
| below 0.38 | `deny` | B (0.362) |
| 0.38 up to, not including, 0.60 | `escalate` | C (0.540) |
| 0.60 and above | `grant` | A (0.766) |

## Status and abstain

Every response has a `status`:

* `scored`: there is a score and your cut points turned it into an outcome.
* `no_outcomes`: there is a score, but the request sent no `outcomes`, so there is no `outcome`. 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. `outcome` is `outcomes.abstain` when 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.

```json theme={null}
{
  "status": "abstained",
  "outcome": "escalate",
  "nodes": {
    "item_used": { "type": "boolean", "abstained": true },
    "grant_score": { "type": "combine", "abstained": true, "reason": "input \"item_used\" had no answer" },
    "grant": { "type": "calibrate", "abstained": true, "reason": "input \"grant_score\" had no answer" }
  }
}
```

This excerpt leaves out the four features that answered.

## 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_score` and then `grant` abstain. The call succeeds with status `abstained`, 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.features` lists 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 503 `ENGINE_UNAVAILABLE` with `Retry-After` instead: retry it with the same `Idempotency-Key`.

When a node has both an abstained and a failed input, failed wins.

A feature the score doesn't depend on can fail without failing the call. That is an **observe-only** feature: one no aggregator reads, which the engine answers and the evaluation records but which never moves the score. If Northpine added a `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:

```json theme={null}
"warnings": [
  { "code": "FEATURE_FAILED", "message": "\"carrier_delay\" had no answer: timeout", "param": "nodes.carrier_delay" }
]
```

## 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 in `outcomes`, 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.

## Next

[Evaluate content](https://docs.moonbounce.ai/learn/evaluations.md) covers the request that carries these answers and cut points, and everything in the response besides the score. [Feedback](https://docs.moonbounce.ai/learn/feedback.md) explains how a ticket like C gets corrected.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.