> ## 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.

# Aggregations

> Every aggregation type, what its value means, how missing answers flow through it, and patterns for putting them together.

> [!NOTE]
>
> The examples on this page use one decision, `refund-exception`, from an online store's support app: should a refund request outside the store's standard return policy be granted, escalated to a person, or denied? [How Moonbounce works](https://docs.moonbounce.ai/learn/how-it-works.md#the-running-example) introduces it.

An **aggregation** is a feature Moonbounce computes from other features: questions, attributes, or other aggregations. None of them learns anything. Each does exactly what its definition says, with every weight, threshold and cut point written in your structure and hashed with it. [Describe a decision](https://docs.moonbounce.ai/learn/decisions.md) builds the refund decision with two of them; this page lists all eleven and how to choose.

## Four kinds

| Kind | Types | Value |
| - | - | - |
| Weighted average | `weighted_mean` | 0 to 1, ranks items, but is not the probability of anything |
| Probability | `logistic`, `min`, `max`, `product`, `noisy_or` | 0 to 1, the probability of a statement |
| Check | `all`, `any` | 1 (true) or 0 (false) |
| Outcome | `bands`, `rules`, `match` | an outcome name |

Every type but the outcome ones produces a number, so they nest freely: a check can feed a probability, an average can feed a check, and any of them can be the decision's `result`. An outcome aggregation produces a name, so only `rules` and `match` can read it.

## Shared rules

* Numeric and check aggregations list the features they read in `inputs: [{ "name": … }]`, with that type's per-input fields next to the name. `bands` and `match` read a single feature, named in `input`; `rules` read the features named in their conditions.
* A reference is the feature's path: `questions.<name>`, `attributes.<name>` or `aggregations.<name>`, in `inputs`, `input`, rule conditions and `result` alike. A bare name, or a path under the wrong root, is a 400 that says which path to write.
* `invert: true` reads an input as 1 − x, a "not". `logistic` uses a negative weight instead.
* The order of `inputs` doesn't matter: reordering them changes neither the hash nor the value. Evaluation uses the same canonical input order as hashing, including for floating-point sums and products.
* Non-finite numbers, question answers outside 0 to 1, and numeric overflow become unknown. A rule can handle an unknown input explicitly with `abstained: true`.
* An input that names a 0-to-1 type must be a 0-to-1 value: a question, a boolean attribute, a number attribute declared with `minimum: 0` and `maximum: 1`, or another number-valued aggregation.

## Weighted averages

**`weighted_mean`** computes Σ wᵢ·xᵢ / Σ wᵢ. Use it for anything where you decide how much each feature counts: quality rubrics, ranking, risk indexes, rewards.

```json theme={null}
"quality": {
  "type": "weighted_mean",
  "inputs": [
    { "name": "questions.is_helpful", "weight": 0.5 },
    { "name": "questions.is_accurate", "weight": 0.3 },
    { "name": "questions.is_concise", "weight": 0.2 }
  ]
}
```

* **Weights are relative.** 0.5, 0.3, 0.2 means the same as 5, 3, 2. Give every input a weight, or none for a plain mean. The calculation scales the known inputs' weights together to avoid overflow and tiny-weight underflow.
* **Weights are positive.** Direction comes from `invert`.
* **It is not a probability.** If `is_helpful` is 0.9 and `is_concise` is 0.3, their average of 0.6 is not the chance that anything happens. That is what a rubric needs.
* **An average can't veto.** An unsafe answer with a small weight still rates high if everything else is good. Gate it with `min`, as in [A gate on an average](#a-gate-on-an-average).

## Probabilities

These read 0-to-1 values and output the probability of a statement.

| Type | Value | Use it for |
| - | - | - |
| `min` | min xᵢ | All of these must hold, when they overlap (several questions about the same thing) |
| `max` | max xᵢ | Any of these is enough, when they overlap |
| `product` | Π xᵢ | All of these must hold, when they are independent (unrelated checks) |
| `noisy_or` | 1 − Π(1 − xᵢ) | Any of these is enough, when they are independent |
| `logistic` | σ(bias + Σ wᵢ·xᵢ) | Fitted weights, from your own classifier or a fit on labeled data |

### Choosing `min`, `max`, `product` or `noisy_or`

`product` and `noisy_or` assume the inputs are independent; `min` and `max` assume they overlap as much as possible. The true answer always lies between the two.

| Answers | `min` | `product` | `max` | `noisy_or` |
| - | - | - | - | - |
| 0.9, 0.9, 0.9 | 0.90 | 0.73 | 0.90 | 0.999 |
| 0.5, 0.5, 0.5 | 0.50 | 0.125 | 0.50 | 0.875 |

Use `product` and `noisy_or` for unrelated checks, such as spam and abuse. Use `min` and `max` for several questions about the same thing. With correlated inputs, `product` keeps dropping as checks are added, and `noisy_or` counts the same evidence more than once.

### `logistic`

The place for fitted numbers: the coefficients and intercept from a logistic regression you trained on labeled data paste in directly.

```json theme={null}
"needs_human": {
  "type": "logistic",
  "bias": -2.0,
  "inputs": [
    { "name": "questions.mentions_refund", "weight": 1.5 },
    { "name": "questions.is_angry", "weight": 2.5 },
    { "name": "questions.within_policy", "weight": -3.0 }
  ]
}
```

* **Weights are signed and absolute.** A negative weight lowers the result, so there is no `invert`.
* **`bias` and every weight are required.** `bias` sets the result when every input is 0; without one, no evidence would always give 0.5.
* **Inputs are raw values,** not log-odds, so `logistic` also reads number attributes outside 0 to 1, such as an account's age in days.

Without a fit, use one of the other types: a guessed logistic looks more precise than it is.

## Checks

**`all`** and **`any`** compare each input to a threshold and output 1 or 0. Because a check produces a number, it nests anywhere: inside a probability, inside `weighted_mean` as the share of checks passed, or as the result.

```json theme={null}
"needs_review": {
  "type": "all",
  "inputs": [
    { "name": "questions.is_new_account" },
    { "name": "questions.has_link" },
    { "name": "questions.is_spam", "gte": 0.7 }
  ]
}
```

* **The default threshold is ≥ 0.5.** A bare entry means "at least 0.5", which for a yes/no question reads as "more likely yes than no". Writing `"gte": 0.5` hashes the same as leaving it out.
* **Other thresholds** use `gte` (≥), `gt` (>), `lte` (≤) or `lt` (\<), one per entry, within the feature's range. A number outside 0 to 1 has no default, so it always names one.
* **Thresholds are part of the structure.** Changing one is a new version, like changing a question.

## Outcome aggregations

**`bands`** cuts one number into ranges, **`rules`** names an outcome from an ordered list of conditions, and **`match`** maps a label straight to an outcome. [From answers to an outcome](https://docs.moonbounce.ai/learn/scores-and-outcomes.md) covers all three with the refund tickets, and what each puts in the response's `outcome`, `value` and `via`.

`match` refuses the case key `__proto__`. To route that exact label, use `rules` with an `is` condition instead.

## What can read what

| Reader | Accepts |
| - | - |
| `weighted_mean`, `min`, `max`, `product`, `noisy_or` | A 0-to-1 value |
| `logistic`, `all`, `any`, `bands` | Any number: a 0-to-1 value, or a number attribute (with an explicit threshold or cut) |
| A comparison in a `rules` condition | Any number |
| `match`, and `is` / `in` in a `rules` condition | A label: a string attribute or another outcome aggregation |

## Missing answers

A question with no answer, because the engine abstained, is **unknown**, and so is any aggregation that can't be computed without it:

* `weighted_mean` leaves the input out, averages the rest and lists it in `missing`. It is unknown only when nothing is left.
* `logistic`, `min`, `max`, `product` and `noisy_or` are unknown when any input is.
* `all` is false as soon as a check fails, and unknown if none failed but an answer is missing. `any` is true as soon as a check passes, and unknown if none passed and one is missing.
* `bands` and `match` give their `abstain` outcome, or are unknown.
* `rules` stop at the first rule that is true or unknown; an unknown one makes the result unknown. A rule with `{ "name": "…", "abstained": true }` catches it first.

An unknown result makes the evaluation's `status` `abstained`. Whatever the status, the evaluation's top-level `missing` lists every question the result depends on that has no answer, so you can tell a `decided` result that used every answer from one that went without some. It is absent when nothing is missing.

## Validation

The structure is checked before anything runs. The graph may not loop: an aggregation that reads itself, directly or through others, is a 400 whose message names the cycle, such as `aggregations form a cycle: quality → reward → quality`. The full list of rules is on [Describe a decision](https://docs.moonbounce.ai/learn/decisions.md#validation).

## Patterns

### Group reasons, then weigh the groups

The refund decision's `carrier_delay` question, "Did the carrier deliver the order late, or lose it?", is the fix the loop finds for ticket C, the late tent. Added as a sixth input of the plain average, it lifts C from 0.540 to 0.612, still `escalate` under the 0.72 grant cut. And it says "no" on almost every other ticket, so it drags them all down by about a sixth: ticket A, the ripped zipper, falls from 0.766 to 0.642 and loses its `grant`.

The policy is really "any failure of ours is a reason to grant", weighed against everything else. A defect and a late carrier are two independent ways that can happen, and a ticket needs only one. So group them with `noisy_or`, group the two reasons for caution with `max`, and weigh the groups:

```json theme={null}
"aggregations": {
  "our_fault": {
    "type": "noisy_or",
    "inputs": [{ "name": "questions.defective" }, { "name": "questions.carrier_delay" }]
  },
  "caution": {
    "type": "max",
    "inputs": [{ "name": "questions.repeat_refunder" }, { "name": "questions.high_value" }]
  },
  "grant_score": {
    "type": "weighted_mean",
    "inputs": [
      { "name": "questions.outside_window", "invert": true, "weight": 1 },
      { "name": "questions.item_used", "invert": true, "weight": 1 },
      { "name": "aggregations.our_fault", "weight": 3 },
      { "name": "aggregations.caution", "invert": true, "weight": 1 }
    ]
  },
  "handling": {
    "type": "bands",
    "input": "aggregations.grant_score",
    "below": "deny",
    "cuts": [{ "at": 0.38, "outcome": "escalate" }, { "at": 0.72, "outcome": "grant" }],
    "abstain": "escalate"
  }
}
```

```mermaid theme={null}
graph LR
  defective --> our_fault
  carrier_delay --> our_fault
  repeat_refunder --> caution
  high_value --> caution
  outside_window -- invert --> grant_score
  item_used -- invert --> grant_score
  our_fault -- weight 3 --> grant_score
  caution -- invert --> grant_score
  grant_score --> handling
```

| Ticket | Five questions | Six, flat | Grouped |
| - | - | - | - |
| A: zipper ripped | 0.766 grant | 0.642 escalate | 0.795 grant |
| B: kayak, changed mind | 0.362 deny | 0.305 deny | 0.156 deny |
| C: tent, carrier late | 0.540 escalate | 0.612 escalate | 0.764 grant |

A's `our_fault` is 0.961 because of its defect, and C's is 0.972 because of its carrier, so the "no" on the other question barely counts against either ticket. The response shows every aggregation's value, so it explains the outcome one level at a time.

Group features because they share a reason, and check that the weights that follow still make sense: `caution` as a whole now weighs as much as `outside_window` alone.

### A gate on an average

An average can't veto, so put a `min` on top. Here an RL reward is a weighted rubric, held down whenever the response is unsafe, however good it is otherwise:

```json theme={null}
"aggregations": {
  "quality": {
    "type": "weighted_mean",
    "inputs": [
      { "name": "questions.is_helpful", "weight": 0.5 },
      { "name": "questions.is_accurate", "weight": 0.3 },
      { "name": "questions.is_concise", "weight": 0.2 }
    ]
  },
  "reward": {
    "type": "min",
    "inputs": [{ "name": "aggregations.quality" }, { "name": "questions.is_unsafe", "invert": true }]
  }
},
"result": "aggregations.reward"
```

The result is the reward value itself, with status `computed` and no outcome.

### Let one strong yes decide

An average dilutes: in a three-input mean, one input moves the value by at most a third. When any single reason should be enough, use `max` (the reasons overlap) or `noisy_or` (they are independent). A camp stove that caught fire, with `legal_threat` 0.02, `safety_issue` 0.97 and `public_complaint` 0.03:

| Answers | `weighted_mean` | `max` | `noisy_or` |
| - | - | - | - |
| 0.02, 0.97, 0.03 | 0.34 | 0.97 | 0.971 |
| 0.02, 0.03, 0.01 | 0.02 | 0.03 | 0.059 |
| 0.30, 0.02, 0.30 | 0.207 | 0.30 | 0.520 |
| 0.40, 0.40, 0.40 | 0.40 | 0.40 | 0.784 |

`noisy_or` adds up weak evidence; `max` never does.

### Rules that mix attributes and answers

An attribute your app supplies combines with a question in one condition. A stricter spam policy in the EU:

```json theme={null}
"handling": {
  "type": "rules",
  "rules": [
    { "when": { "all": [{ "name": "attributes.region", "is": "eu" }, { "name": "questions.is_spam", "gte": 0.6 }] }, "outcome": "block" },
    { "when": { "name": "questions.is_spam", "gte": 0.9 }, "outcome": "block" }
  ],
  "else": "allow"
}
```

### Try a question before it counts

A question that no aggregation reads, and that isn't the `result`, is **observe-only**: the engine answers it and the evaluation records it, but it never moves the outcome, and its failure never fails the call. Add a question that way, watch its answers on real traffic, and wire it in once it answers the way you expect.

## Next

[From answers to an outcome](https://docs.moonbounce.ai/learn/scores-and-outcomes.md) follows the refund tickets through `bands`, and covers `rules`, `match` and the status.


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